From b8056e69e0a2505fc466dd5742b0986b7c1895ae Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Wed, 29 Apr 2020 19:08:08 +0200
Subject: [PATCH 01/76] Object Validator Types: Add Recipients.

---
 .../object_validators/types/recipients.ex     | 34 +++++++++++++++++++
 .../types/recipients_test.exs                 | 27 +++++++++++++++
 2 files changed, 61 insertions(+)
 create mode 100644 lib/pleroma/web/activity_pub/object_validators/types/recipients.ex
 create mode 100644 test/web/activity_pub/object_validators/types/recipients_test.exs

diff --git a/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex
new file mode 100644
index 000000000..48fe61e1a
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex
@@ -0,0 +1,34 @@
+defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients do
+  use Ecto.Type
+
+  alias Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID
+
+  def type, do: {:array, ObjectID}
+
+  def cast(object) when is_binary(object) do
+    cast([object])
+  end
+
+  def cast(data) when is_list(data) do
+    data
+    |> Enum.reduce({:ok, []}, fn element, acc ->
+      case {acc, ObjectID.cast(element)} do
+        {:error, _} -> :error
+        {_, :error} -> :error
+        {{:ok, list}, {:ok, id}} -> {:ok, [id | list]}
+      end
+    end)
+  end
+
+  def cast(_) do
+    :error
+  end
+
+  def dump(data) do
+    {:ok, data}
+  end
+
+  def load(data) do
+    {:ok, data}
+  end
+end
diff --git a/test/web/activity_pub/object_validators/types/recipients_test.exs b/test/web/activity_pub/object_validators/types/recipients_test.exs
new file mode 100644
index 000000000..f278f039b
--- /dev/null
+++ b/test/web/activity_pub/object_validators/types/recipients_test.exs
@@ -0,0 +1,27 @@
+defmodule Pleroma.Web.ObjectValidators.Types.RecipientsTest do
+  alias Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients
+  use Pleroma.DataCase
+
+  test "it asserts that all elements of the list are object ids" do
+    list = ["https://lain.com/users/lain", "invalid"]
+
+    assert :error == Recipients.cast(list)
+  end
+
+  test "it works with a list" do
+    list = ["https://lain.com/users/lain"]
+    assert {:ok, list} == Recipients.cast(list)
+  end
+
+  test "it works with a list with whole objects" do
+    list = ["https://lain.com/users/lain", %{"id" => "https://gensokyo.2hu/users/raymoo"}]
+    resulting_list = ["https://gensokyo.2hu/users/raymoo", "https://lain.com/users/lain"]
+    assert {:ok, resulting_list} == Recipients.cast(list)
+  end
+
+  test "it turns a single string into a list" do
+    recipient = "https://lain.com/users/lain"
+
+    assert {:ok, [recipient]} == Recipients.cast(recipient)
+  end
+end

From 78c864cbeed8fcdbe80e2842377d4fabc9362f3c Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Wed, 29 Apr 2020 19:08:36 +0200
Subject: [PATCH 02/76] LikeValidator: Use Recipients Type.

---
 .../web/activity_pub/object_validators/like_validator.ex      | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex
index 49546ceaa..eeb0da192 100644
--- a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex
@@ -19,8 +19,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator do
     field(:object, Types.ObjectID)
     field(:actor, Types.ObjectID)
     field(:context, :string)
-    field(:to, {:array, :string})
-    field(:cc, {:array, :string})
+    field(:to, Types.Recipients)
+    field(:cc, Types.Recipients)
   end
 
   def cast_and_validate(data) do

From 503de4b8df0bfc34008c3c856edc488633290f0e Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Wed, 29 Apr 2020 19:09:51 +0200
Subject: [PATCH 03/76] ObjectValidator: Add validation for `Delete`s.

---
 lib/pleroma/web/activity_pub/builder.ex       | 16 +++++
 .../web/activity_pub/object_validator.ex      | 17 +++++
 .../object_validators/common_validations.ex   | 20 ++++++
 .../object_validators/delete_validator.ex     | 64 ++++++++++++++++++
 .../activity_pub/object_validator_test.exs    | 67 +++++++++++++++++++
 5 files changed, 184 insertions(+)
 create mode 100644 lib/pleroma/web/activity_pub/object_validators/delete_validator.ex

diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex
index 429a510b8..5cc46c3ea 100644
--- a/lib/pleroma/web/activity_pub/builder.ex
+++ b/lib/pleroma/web/activity_pub/builder.ex
@@ -10,6 +10,22 @@ defmodule Pleroma.Web.ActivityPub.Builder do
   alias Pleroma.Web.ActivityPub.Utils
   alias Pleroma.Web.ActivityPub.Visibility
 
+  @spec delete(User.t(), String.t()) :: {:ok, map(), keyword()}
+  def delete(actor, object_id) do
+    object = Object.normalize(object_id)
+
+    to = (object.data["to"] || []) ++ (object.data["cc"] || [])
+
+    {:ok,
+     %{
+       "id" => Utils.generate_activity_id(),
+       "actor" => actor.ap_id,
+       "object" => object_id,
+       "to" => to,
+       "type" => "Delete"
+     }, []}
+  end
+
   @spec like(User.t(), Object.t()) :: {:ok, map(), keyword()}
   def like(actor, object) do
     object_actor = User.get_cached_by_ap_id(object.data["actor"])
diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex
index dc4bce059..f476c6f72 100644
--- a/lib/pleroma/web/activity_pub/object_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validator.ex
@@ -12,10 +12,21 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
   alias Pleroma.Object
   alias Pleroma.User
   alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator
+  alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator
 
   @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()}
   def validate(object, meta)
 
+  def validate(%{"type" => "Delete"} = object, meta) do
+    with {:ok, object} <-
+           object
+           |> DeleteValidator.cast_and_validate()
+           |> Ecto.Changeset.apply_action(:insert) do
+      object = stringify_keys(object)
+      {:ok, object, meta}
+    end
+  end
+
   def validate(%{"type" => "Like"} = object, meta) do
     with {:ok, object} <-
            object |> LikeValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do
@@ -24,6 +35,12 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
     end
   end
 
+  def stringify_keys(%{__struct__: _} = object) do
+    object
+    |> Map.from_struct()
+    |> stringify_keys
+  end
+
   def stringify_keys(object) do
     object
     |> Map.new(fn {key, val} -> {to_string(key), val} end)
diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex
index b479c3918..e115d9526 100644
--- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex
@@ -8,6 +8,26 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do
   alias Pleroma.Object
   alias Pleroma.User
 
+  def validate_recipients_presence(cng, fields \\ [:to, :cc]) do
+    non_empty =
+      fields
+      |> Enum.map(fn field -> get_field(cng, field) end)
+      |> Enum.any?(fn
+        [] -> false
+        _ -> true
+      end)
+
+    if non_empty do
+      cng
+    else
+      fields
+      |> Enum.reduce(cng, fn field, cng ->
+        cng
+        |> add_error(field, "no recipients in any field")
+      end)
+    end
+  end
+
   def validate_actor_presence(cng, field_name \\ :actor) do
     cng
     |> validate_change(field_name, fn field_name, actor ->
diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
new file mode 100644
index 000000000..8dd5c19ad
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
@@ -0,0 +1,64 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do
+  use Ecto.Schema
+
+  alias Pleroma.Web.ActivityPub.ObjectValidators.Types
+
+  import Ecto.Changeset
+  import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations
+
+  @primary_key false
+
+  embedded_schema do
+    field(:id, Types.ObjectID, primary_key: true)
+    field(:type, :string)
+    field(:actor, Types.ObjectID)
+    field(:to, Types.Recipients, default: [])
+    field(:cc, Types.Recipients, default: [])
+    field(:object, Types.ObjectID)
+  end
+
+  def cast_data(data) do
+    %__MODULE__{}
+    |> cast(data, __schema__(:fields))
+  end
+
+  def validate_data(cng) do
+    cng
+    |> validate_required([:id, :type, :actor, :to, :cc, :object])
+    |> validate_inclusion(:type, ["Delete"])
+    |> validate_same_domain()
+    |> validate_object_presence()
+    |> validate_recipients_presence()
+  end
+
+  def validate_same_domain(cng) do
+    actor_domain =
+      cng
+      |> get_field(:actor)
+      |> URI.parse()
+      |> (& &1.host).()
+
+    object_domain =
+      cng
+      |> get_field(:object)
+      |> URI.parse()
+      |> (& &1.host).()
+
+    if object_domain != actor_domain do
+      cng
+      |> add_error(:actor, "is not allowed to delete object")
+    else
+      cng
+    end
+  end
+
+  def cast_and_validate(data) do
+    data
+    |> cast_data
+    |> validate_data
+  end
+end
diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs
index 3c5c3696e..64b9ee1ec 100644
--- a/test/web/activity_pub/object_validator_test.exs
+++ b/test/web/activity_pub/object_validator_test.exs
@@ -1,6 +1,7 @@
 defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do
   use Pleroma.DataCase
 
+  alias Pleroma.Web.ActivityPub.Builder
   alias Pleroma.Web.ActivityPub.ObjectValidator
   alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator
   alias Pleroma.Web.ActivityPub.Utils
@@ -8,6 +9,72 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do
 
   import Pleroma.Factory
 
+  describe "deletes" do
+    setup do
+      user = insert(:user)
+      {:ok, post_activity} = CommonAPI.post(user, %{"status" => "cancel me daddy"})
+
+      {:ok, valid_post_delete, _} = Builder.delete(user, post_activity.data["object"])
+
+      %{user: user, valid_post_delete: valid_post_delete}
+    end
+
+    test "it is valid for a post deletion", %{valid_post_delete: valid_post_delete} do
+      assert match?({:ok, _, _}, ObjectValidator.validate(valid_post_delete, []))
+    end
+
+    test "it's invalid if the id is missing", %{valid_post_delete: valid_post_delete} do
+      no_id =
+        valid_post_delete
+        |> Map.delete("id")
+
+      {:error, cng} = ObjectValidator.validate(no_id, [])
+
+      assert {:id, {"can't be blank", [validation: :required]}} in cng.errors
+    end
+
+    test "it's invalid if the object doesn't exist", %{valid_post_delete: valid_post_delete} do
+      missing_object =
+        valid_post_delete
+        |> Map.put("object", "http://does.not/exist")
+
+      {:error, cng} = ObjectValidator.validate(missing_object, [])
+
+      assert {:object, {"can't find object", []}} in cng.errors
+    end
+
+    test "it's invalid if the actor of the object and the actor of delete are from different domains",
+         %{valid_post_delete: valid_post_delete} do
+      valid_other_actor =
+        valid_post_delete
+        |> Map.put("actor", valid_post_delete["actor"] <> "1")
+
+      assert match?({:ok, _, _}, ObjectValidator.validate(valid_other_actor, []))
+
+      invalid_other_actor =
+        valid_post_delete
+        |> Map.put("actor", "https://gensokyo.2hu/users/raymoo")
+
+      {:error, cng} = ObjectValidator.validate(invalid_other_actor, [])
+
+      assert {:actor, {"is not allowed to delete object", []}} in cng.errors
+    end
+
+    test "it's invalid if all the recipient fields are empty", %{
+      valid_post_delete: valid_post_delete
+    } do
+      empty_recipients =
+        valid_post_delete
+        |> Map.put("to", [])
+        |> Map.put("cc", [])
+
+      {:error, cng} = ObjectValidator.validate(empty_recipients, [])
+
+      assert {:to, {"no recipients in any field", []}} in cng.errors
+      assert {:cc, {"no recipients in any field", []}} in cng.errors
+    end
+  end
+
   describe "likes" do
     setup do
       user = insert(:user)

From 64bb72f98a91261158b36e63f6c9634ac9f423a6 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 13:57:47 +0200
Subject: [PATCH 04/76] Typo fix.

---
 lib/pleroma/web/activity_pub/utils.ex | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex
index 2d685ecc0..1a3b0b3c1 100644
--- a/lib/pleroma/web/activity_pub/utils.ex
+++ b/lib/pleroma/web/activity_pub/utils.ex
@@ -512,7 +512,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do
   #### Announce-related helpers
 
   @doc """
-  Retruns an existing announce activity if the notice has already been announced
+  Returns an existing announce activity if the notice has already been announced
   """
   @spec get_existing_announce(String.t(), map()) :: Activity.t() | nil
   def get_existing_announce(actor, %{data: %{"id" => ap_id}}) do

From 42ce7c5164326aa577bc7bd18e98c5d0a9d6fea5 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 14:13:08 +0200
Subject: [PATCH 05/76] ObjectValidator: Add actor fetcher.

---
 lib/pleroma/web/activity_pub/object_validator.ex | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex
index f476c6f72..016f6e7a2 100644
--- a/lib/pleroma/web/activity_pub/object_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validator.ex
@@ -46,8 +46,14 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
     |> Map.new(fn {key, val} -> {to_string(key), val} end)
   end
 
+  def fetch_actor(object) do
+    with {:ok, actor} <- Types.ObjectID.cast(object["actor"]) do
+      User.get_or_fetch_by_ap_id(actor)
+    end
+  end
+
   def fetch_actor_and_object(object) do
-    User.get_or_fetch_by_ap_id(object["actor"])
+    fetch_actor(object)
     Object.normalize(object["object"])
     :ok
   end

From bd219ba7e884d694cc1c8747f0b48cd646821222 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 14:14:00 +0200
Subject: [PATCH 06/76] Transmogrifier Tests: Extract deletion tests.

---
 .../transmogrifier/delete_handling_test.exs   | 106 ++++++++++++++++++
 test/web/activity_pub/transmogrifier_test.exs |  77 -------------
 2 files changed, 106 insertions(+), 77 deletions(-)
 create mode 100644 test/web/activity_pub/transmogrifier/delete_handling_test.exs

diff --git a/test/web/activity_pub/transmogrifier/delete_handling_test.exs b/test/web/activity_pub/transmogrifier/delete_handling_test.exs
new file mode 100644
index 000000000..c15de5a95
--- /dev/null
+++ b/test/web/activity_pub/transmogrifier/delete_handling_test.exs
@@ -0,0 +1,106 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.Transmogrifier.DeleteHandlingTest do
+  use Oban.Testing, repo: Pleroma.Repo
+  use Pleroma.DataCase
+
+  alias Pleroma.Activity
+  alias Pleroma.Object
+  alias Pleroma.Tests.ObanHelpers
+  alias Pleroma.User
+  alias Pleroma.Web.ActivityPub.Transmogrifier
+
+  import Pleroma.Factory
+  import ExUnit.CaptureLog
+
+  setup_all do
+    Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
+    :ok
+  end
+
+  test "it works for incoming deletes" do
+    activity = insert(:note_activity)
+    deleting_user = insert(:user)
+
+    data =
+      File.read!("test/fixtures/mastodon-delete.json")
+      |> Poison.decode!()
+
+    object =
+      data["object"]
+      |> Map.put("id", activity.data["object"])
+
+    data =
+      data
+      |> Map.put("object", object)
+      |> Map.put("actor", deleting_user.ap_id)
+
+    {:ok, %Activity{actor: actor, local: false, data: %{"id" => id}}} =
+      Transmogrifier.handle_incoming(data)
+
+    assert id == data["id"]
+
+    # We delete the Create activity because base our timelines on it.
+    # This should be changed after we unify objects and activities
+    refute Activity.get_by_id(activity.id)
+    assert actor == deleting_user.ap_id
+
+    # Objects are replaced by a tombstone object.
+    object = Object.normalize(activity.data["object"])
+    assert object.data["type"] == "Tombstone"
+  end
+
+  test "it fails for incoming deletes with spoofed origin" do
+    activity = insert(:note_activity)
+
+    data =
+      File.read!("test/fixtures/mastodon-delete.json")
+      |> Poison.decode!()
+
+    object =
+      data["object"]
+      |> Map.put("id", activity.data["object"])
+
+    data =
+      data
+      |> Map.put("object", object)
+
+    assert capture_log(fn ->
+             :error = Transmogrifier.handle_incoming(data)
+           end) =~
+             "[error] Could not decode user at fetch http://mastodon.example.org/users/gargron, {:error, :nxdomain}"
+
+    assert Activity.get_by_id(activity.id)
+  end
+
+  @tag capture_log: true
+  test "it works for incoming user deletes" do
+    %{ap_id: ap_id} = insert(:user, ap_id: "http://mastodon.example.org/users/admin")
+
+    data =
+      File.read!("test/fixtures/mastodon-delete-user.json")
+      |> Poison.decode!()
+
+    {:ok, _} = Transmogrifier.handle_incoming(data)
+    ObanHelpers.perform_all()
+
+    refute User.get_cached_by_ap_id(ap_id)
+  end
+
+  test "it fails for incoming user deletes with spoofed origin" do
+    %{ap_id: ap_id} = insert(:user)
+
+    data =
+      File.read!("test/fixtures/mastodon-delete-user.json")
+      |> Poison.decode!()
+      |> Map.put("actor", ap_id)
+
+    assert capture_log(fn ->
+             assert :error == Transmogrifier.handle_incoming(data)
+           end) =~ "Object containment failed"
+
+    assert User.get_cached_by_ap_id(ap_id)
+  end
+end
diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs
index 6057e360a..64e56d378 100644
--- a/test/web/activity_pub/transmogrifier_test.exs
+++ b/test/web/activity_pub/transmogrifier_test.exs
@@ -822,83 +822,6 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
       assert user.locked == true
     end
 
-    test "it works for incoming deletes" do
-      activity = insert(:note_activity)
-      deleting_user = insert(:user)
-
-      data =
-        File.read!("test/fixtures/mastodon-delete.json")
-        |> Poison.decode!()
-
-      object =
-        data["object"]
-        |> Map.put("id", activity.data["object"])
-
-      data =
-        data
-        |> Map.put("object", object)
-        |> Map.put("actor", deleting_user.ap_id)
-
-      {:ok, %Activity{actor: actor, local: false, data: %{"id" => id}}} =
-        Transmogrifier.handle_incoming(data)
-
-      assert id == data["id"]
-      refute Activity.get_by_id(activity.id)
-      assert actor == deleting_user.ap_id
-    end
-
-    test "it fails for incoming deletes with spoofed origin" do
-      activity = insert(:note_activity)
-
-      data =
-        File.read!("test/fixtures/mastodon-delete.json")
-        |> Poison.decode!()
-
-      object =
-        data["object"]
-        |> Map.put("id", activity.data["object"])
-
-      data =
-        data
-        |> Map.put("object", object)
-
-      assert capture_log(fn ->
-               :error = Transmogrifier.handle_incoming(data)
-             end) =~
-               "[error] Could not decode user at fetch http://mastodon.example.org/users/gargron, {:error, :nxdomain}"
-
-      assert Activity.get_by_id(activity.id)
-    end
-
-    @tag capture_log: true
-    test "it works for incoming user deletes" do
-      %{ap_id: ap_id} = insert(:user, ap_id: "http://mastodon.example.org/users/admin")
-
-      data =
-        File.read!("test/fixtures/mastodon-delete-user.json")
-        |> Poison.decode!()
-
-      {:ok, _} = Transmogrifier.handle_incoming(data)
-      ObanHelpers.perform_all()
-
-      refute User.get_cached_by_ap_id(ap_id)
-    end
-
-    test "it fails for incoming user deletes with spoofed origin" do
-      %{ap_id: ap_id} = insert(:user)
-
-      data =
-        File.read!("test/fixtures/mastodon-delete-user.json")
-        |> Poison.decode!()
-        |> Map.put("actor", ap_id)
-
-      assert capture_log(fn ->
-               assert :error == Transmogrifier.handle_incoming(data)
-             end) =~ "Object containment failed"
-
-      assert User.get_cached_by_ap_id(ap_id)
-    end
-
     test "it works for incoming unannounces with an existing notice" do
       user = insert(:user)
       {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"})

From db184a8eb495865334f47a24f8c5b1fec65450b6 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 14:37:14 +0200
Subject: [PATCH 07/76] DeleteValidator: Mastodon sends unaddressed deletes.

---
 .../object_validators/delete_validator.ex          |  1 -
 test/web/activity_pub/object_validator_test.exs    | 14 --------------
 2 files changed, 15 deletions(-)

diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
index 8dd5c19ad..0eb31451c 100644
--- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
@@ -32,7 +32,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do
     |> validate_inclusion(:type, ["Delete"])
     |> validate_same_domain()
     |> validate_object_presence()
-    |> validate_recipients_presence()
   end
 
   def validate_same_domain(cng) do
diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs
index 64b9ee1ec..ab26d3501 100644
--- a/test/web/activity_pub/object_validator_test.exs
+++ b/test/web/activity_pub/object_validator_test.exs
@@ -59,20 +59,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do
 
       assert {:actor, {"is not allowed to delete object", []}} in cng.errors
     end
-
-    test "it's invalid if all the recipient fields are empty", %{
-      valid_post_delete: valid_post_delete
-    } do
-      empty_recipients =
-        valid_post_delete
-        |> Map.put("to", [])
-        |> Map.put("cc", [])
-
-      {:error, cng} = ObjectValidator.validate(empty_recipients, [])
-
-      assert {:to, {"no recipients in any field", []}} in cng.errors
-      assert {:cc, {"no recipients in any field", []}} in cng.errors
-    end
   end
 
   describe "likes" do

From 4dc5302f455e56d3c2cb669e8a70f52457690a86 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 15:26:23 +0200
Subject: [PATCH 08/76] Transmogrifier: Handle incoming deletes for non-user
 objects.

---
 .../web/activity_pub/object_validator.ex      |  3 +-
 lib/pleroma/web/activity_pub/side_effects.ex  | 12 ++++++++
 .../web/activity_pub/transmogrifier.ex        | 29 ++-----------------
 test/web/activity_pub/side_effects_test.exs   | 23 +++++++++++++++
 .../transmogrifier/delete_handling_test.exs   |  6 ++--
 5 files changed, 42 insertions(+), 31 deletions(-)

diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex
index 016f6e7a2..32f606917 100644
--- a/lib/pleroma/web/activity_pub/object_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validator.ex
@@ -11,8 +11,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
 
   alias Pleroma.Object
   alias Pleroma.User
-  alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator
   alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator
+  alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator
+  alias Pleroma.Web.ActivityPub.ObjectValidators.Types
 
   @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()}
   def validate(object, meta)
diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex
index 5981e7545..93698a834 100644
--- a/lib/pleroma/web/activity_pub/side_effects.ex
+++ b/lib/pleroma/web/activity_pub/side_effects.ex
@@ -28,6 +28,18 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
     result
   end
 
+  # Tasks this handles:
+  # - Delete create activity
+  # - Replace object with Tombstone
+  # - Set up notification
+  def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, meta) do
+    with %Object{} = deleted_object <- Object.normalize(deleted_object),
+         {:ok, _, _} <- Object.delete(deleted_object) do
+      Notification.create_notifications(object)
+      {:ok, object, meta}
+    end
+  end
+
   # Nothing to do
   def handle(object, meta) do
     {:ok, object, meta}
diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex
index 09119137b..855aab8d4 100644
--- a/lib/pleroma/web/activity_pub/transmogrifier.ex
+++ b/lib/pleroma/web/activity_pub/transmogrifier.ex
@@ -729,36 +729,13 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
     end
   end
 
-  # TODO: We presently assume that any actor on the same origin domain as the object being
-  # deleted has the rights to delete that object.  A better way to validate whether or not
-  # the object should be deleted is to refetch the object URI, which should return either
-  # an error or a tombstone.  This would allow us to verify that a deletion actually took
-  # place.
   def handle_incoming(
-        %{"type" => "Delete", "object" => object_id, "actor" => actor, "id" => id} = data,
+        %{"type" => "Delete"} = data,
         _options
       ) do
-    object_id = Utils.get_ap_id(object_id)
-
-    with actor <- Containment.get_actor(data),
-         {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
-         {:ok, object} <- get_obj_helper(object_id),
-         :ok <- Containment.contain_origin(actor.ap_id, object.data),
-         {:ok, activity} <-
-           ActivityPub.delete(object, local: false, activity_id: id, actor: actor.ap_id) do
+    with {:ok, %User{}} <- ObjectValidator.fetch_actor(data),
+         {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do
       {:ok, activity}
-    else
-      nil ->
-        case User.get_cached_by_ap_id(object_id) do
-          %User{ap_id: ^actor} = user ->
-            User.delete(user)
-
-          nil ->
-            :error
-        end
-
-      _e ->
-        :error
     end
   end
 
diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs
index 0b6b55156..eec9488e7 100644
--- a/test/web/activity_pub/side_effects_test.exs
+++ b/test/web/activity_pub/side_effects_test.exs
@@ -5,6 +5,7 @@
 defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
   use Pleroma.DataCase
 
+  alias Pleroma.Activity
   alias Pleroma.Notification
   alias Pleroma.Object
   alias Pleroma.Repo
@@ -15,6 +16,28 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
 
   import Pleroma.Factory
 
+  describe "delete objects" do
+    setup do
+      user = insert(:user)
+      {:ok, post} = CommonAPI.post(user, %{"status" => "hey"})
+      object = Object.normalize(post)
+      {:ok, delete_data, _meta} = Builder.delete(user, object.data["id"])
+      {:ok, delete, _meta} = ActivityPub.persist(delete_data, local: true)
+      %{user: user, delete: delete, post: post, object: object}
+    end
+
+    test "it handles object deletions", %{delete: delete, post: post, object: object} do
+      # In object deletions, the object is replaced by a tombstone and the
+      # create activity is deleted
+
+      {:ok, _delete, _} = SideEffects.handle(delete)
+
+      object = Object.get_by_id(object.id)
+      assert object.data["type"] == "Tombstone"
+      refute Activity.get_by_id(post.id)
+    end
+  end
+
   describe "like objects" do
     setup do
       poster = insert(:user)
diff --git a/test/web/activity_pub/transmogrifier/delete_handling_test.exs b/test/web/activity_pub/transmogrifier/delete_handling_test.exs
index c15de5a95..64c908a05 100644
--- a/test/web/activity_pub/transmogrifier/delete_handling_test.exs
+++ b/test/web/activity_pub/transmogrifier/delete_handling_test.exs
@@ -68,7 +68,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.DeleteHandlingTest do
       |> Map.put("object", object)
 
     assert capture_log(fn ->
-             :error = Transmogrifier.handle_incoming(data)
+             {:error, _} = Transmogrifier.handle_incoming(data)
            end) =~
              "[error] Could not decode user at fetch http://mastodon.example.org/users/gargron, {:error, :nxdomain}"
 
@@ -97,9 +97,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.DeleteHandlingTest do
       |> Poison.decode!()
       |> Map.put("actor", ap_id)
 
-    assert capture_log(fn ->
-             assert :error == Transmogrifier.handle_incoming(data)
-           end) =~ "Object containment failed"
+    assert match?({:error, _}, Transmogrifier.handle_incoming(data))
 
     assert User.get_cached_by_ap_id(ap_id)
   end

From 1fb383f368b861d7aea77770ba7be6e3dfe3468e Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 15:42:30 +0200
Subject: [PATCH 09/76] DeleteValidator: Deleting a user is valid.

---
 lib/pleroma/web/activity_pub/builder.ex           | 15 +++++++++++++--
 .../object_validators/common_validations.ex       | 11 +++++++++++
 .../object_validators/delete_validator.ex         |  2 +-
 test/web/activity_pub/object_validator_test.exs   |  7 ++++++-
 4 files changed, 31 insertions(+), 4 deletions(-)

diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex
index 5cc46c3ea..1345a3a3e 100644
--- a/lib/pleroma/web/activity_pub/builder.ex
+++ b/lib/pleroma/web/activity_pub/builder.ex
@@ -12,9 +12,20 @@ defmodule Pleroma.Web.ActivityPub.Builder do
 
   @spec delete(User.t(), String.t()) :: {:ok, map(), keyword()}
   def delete(actor, object_id) do
-    object = Object.normalize(object_id)
+    object = Object.normalize(object_id, false)
 
-    to = (object.data["to"] || []) ++ (object.data["cc"] || [])
+    user = !object && User.get_cached_by_ap_id(object_id)
+
+    to =
+      case {object, user} do
+        {%Object{}, _} ->
+          # We are deleting an object, address everyone who was originally mentioned
+          (object.data["to"] || []) ++ (object.data["cc"] || [])
+
+        {_, %User{follower_address: follower_address}} ->
+          # We are deleting a user, address the followers of that user
+          [follower_address]
+      end
 
     {:ok,
      %{
diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex
index e115d9526..d9a629a34 100644
--- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex
@@ -49,4 +49,15 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do
       end
     end)
   end
+
+  def validate_object_or_user_presence(cng, field_name \\ :object) do
+    cng
+    |> validate_change(field_name, fn field_name, object ->
+      if Object.get_cached_by_ap_id(object) || User.get_cached_by_ap_id(object) do
+        []
+      else
+        [{field_name, "can't find object"}]
+      end
+    end)
+  end
 end
diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
index 0eb31451c..fa1713b50 100644
--- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
@@ -31,7 +31,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do
     |> validate_required([:id, :type, :actor, :to, :cc, :object])
     |> validate_inclusion(:type, ["Delete"])
     |> validate_same_domain()
-    |> validate_object_presence()
+    |> validate_object_or_user_presence()
   end
 
   def validate_same_domain(cng) do
diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs
index ab26d3501..83b21a9bc 100644
--- a/test/web/activity_pub/object_validator_test.exs
+++ b/test/web/activity_pub/object_validator_test.exs
@@ -15,14 +15,19 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do
       {:ok, post_activity} = CommonAPI.post(user, %{"status" => "cancel me daddy"})
 
       {:ok, valid_post_delete, _} = Builder.delete(user, post_activity.data["object"])
+      {:ok, valid_user_delete, _} = Builder.delete(user, user.ap_id)
 
-      %{user: user, valid_post_delete: valid_post_delete}
+      %{user: user, valid_post_delete: valid_post_delete, valid_user_delete: valid_user_delete}
     end
 
     test "it is valid for a post deletion", %{valid_post_delete: valid_post_delete} do
       assert match?({:ok, _, _}, ObjectValidator.validate(valid_post_delete, []))
     end
 
+    test "it is valid for a user deletion", %{valid_user_delete: valid_user_delete} do
+      assert match?({:ok, _, _}, ObjectValidator.validate(valid_user_delete, []))
+    end
+
     test "it's invalid if the id is missing", %{valid_post_delete: valid_post_delete} do
       no_id =
         valid_post_delete

From 417eed4a2b10b0a1fd916839ddb03d0345966123 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 15:57:27 +0200
Subject: [PATCH 10/76] SideEffects: Handle deletions.

---
 lib/pleroma/web/activity_pub/side_effects.ex | 22 ++++++++++++++++++--
 test/web/activity_pub/side_effects_test.exs  | 14 ++++++++++++-
 2 files changed, 33 insertions(+), 3 deletions(-)

diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex
index 93698a834..ac1d4c222 100644
--- a/lib/pleroma/web/activity_pub/side_effects.ex
+++ b/lib/pleroma/web/activity_pub/side_effects.ex
@@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
   """
   alias Pleroma.Notification
   alias Pleroma.Object
+  alias Pleroma.User
   alias Pleroma.Web.ActivityPub.Utils
 
   def handle(object, meta \\ [])
@@ -33,10 +34,27 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
   # - Replace object with Tombstone
   # - Set up notification
   def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, meta) do
-    with %Object{} = deleted_object <- Object.normalize(deleted_object),
-         {:ok, _, _} <- Object.delete(deleted_object) do
+    deleted_object =
+      Object.normalize(deleted_object, false) || User.get_cached_by_ap_id(deleted_object)
+
+    result =
+      case deleted_object do
+        %Object{} ->
+          with {:ok, _, _} <- Object.delete(deleted_object) do
+            :ok
+          end
+
+        %User{} ->
+          with {:ok, _} <- User.delete(deleted_object) do
+            :ok
+          end
+      end
+
+    if result == :ok do
       Notification.create_notifications(object)
       {:ok, object, meta}
+    else
+      {:error, result}
     end
   end
 
diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs
index eec9488e7..b3d0addc7 100644
--- a/test/web/activity_pub/side_effects_test.exs
+++ b/test/web/activity_pub/side_effects_test.exs
@@ -3,12 +3,15 @@
 # SPDX-License-Identifier: AGPL-3.0-only
 
 defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
+  use Oban.Testing, repo: Pleroma.Repo
   use Pleroma.DataCase
 
   alias Pleroma.Activity
   alias Pleroma.Notification
   alias Pleroma.Object
   alias Pleroma.Repo
+  alias Pleroma.User
+  alias Pleroma.Tests.ObanHelpers
   alias Pleroma.Web.ActivityPub.ActivityPub
   alias Pleroma.Web.ActivityPub.Builder
   alias Pleroma.Web.ActivityPub.SideEffects
@@ -22,8 +25,10 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
       {:ok, post} = CommonAPI.post(user, %{"status" => "hey"})
       object = Object.normalize(post)
       {:ok, delete_data, _meta} = Builder.delete(user, object.data["id"])
+      {:ok, delete_user_data, _meta} = Builder.delete(user, user.ap_id)
       {:ok, delete, _meta} = ActivityPub.persist(delete_data, local: true)
-      %{user: user, delete: delete, post: post, object: object}
+      {:ok, delete_user, _meta} = ActivityPub.persist(delete_user_data, local: true)
+      %{user: user, delete: delete, post: post, object: object, delete_user: delete_user}
     end
 
     test "it handles object deletions", %{delete: delete, post: post, object: object} do
@@ -36,6 +41,13 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
       assert object.data["type"] == "Tombstone"
       refute Activity.get_by_id(post.id)
     end
+
+    test "it handles user deletions", %{delete_user: delete, user: user} do
+      {:ok, _delete, _} = SideEffects.handle(delete)
+      ObanHelpers.perform_all()
+
+      refute User.get_cached_by_ap_id(user.ap_id)
+    end
   end
 
   describe "like objects" do

From c9bfa51ea9c0048ffa4c0d3e28c196da2f38e384 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 15:58:37 +0200
Subject: [PATCH 11/76] Credo fixes.

---
 test/web/activity_pub/side_effects_test.exs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs
index b3d0addc7..fffe0ca38 100644
--- a/test/web/activity_pub/side_effects_test.exs
+++ b/test/web/activity_pub/side_effects_test.exs
@@ -10,8 +10,8 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
   alias Pleroma.Notification
   alias Pleroma.Object
   alias Pleroma.Repo
-  alias Pleroma.User
   alias Pleroma.Tests.ObanHelpers
+  alias Pleroma.User
   alias Pleroma.Web.ActivityPub.ActivityPub
   alias Pleroma.Web.ActivityPub.Builder
   alias Pleroma.Web.ActivityPub.SideEffects

From fdd8e7f27697a7128e4e92020cdff6389c999acc Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 16:15:38 +0200
Subject: [PATCH 12/76] CommonAPI: Use common pipeline for deletions.

---
 lib/pleroma/web/activity_pub/side_effects.ex | 6 ++++--
 lib/pleroma/web/common_api/common_api.ex     | 4 ++--
 2 files changed, 6 insertions(+), 4 deletions(-)

diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex
index ac1d4c222..ef58fa399 100644
--- a/lib/pleroma/web/activity_pub/side_effects.ex
+++ b/lib/pleroma/web/activity_pub/side_effects.ex
@@ -30,7 +30,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
   end
 
   # Tasks this handles:
-  # - Delete create activity
+  # - Delete and unpins the create activity
   # - Replace object with Tombstone
   # - Set up notification
   def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, meta) do
@@ -40,7 +40,9 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
     result =
       case deleted_object do
         %Object{} ->
-          with {:ok, _, _} <- Object.delete(deleted_object) do
+          with {:ok, _, activity} <- Object.delete(deleted_object),
+               %User{} = user <- User.get_cached_by_ap_id(deleted_object.data["actor"]) do
+            User.remove_pinnned_activity(user, activity)
             :ok
           end
 
diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex
index d1efe0c36..7cb8e47d0 100644
--- a/lib/pleroma/web/common_api/common_api.ex
+++ b/lib/pleroma/web/common_api/common_api.ex
@@ -77,8 +77,8 @@ defmodule Pleroma.Web.CommonAPI do
            {:find_activity, Activity.get_by_id_with_object(activity_id)},
          %Object{} = object <- Object.normalize(activity),
          true <- User.superuser?(user) || user.ap_id == object.data["actor"],
-         {:ok, _} <- unpin(activity_id, user),
-         {:ok, delete} <- ActivityPub.delete(object) do
+         {:ok, delete_data, _} <- Builder.delete(user, object.data["id"]),
+         {:ok, delete, _} <- Pipeline.common_pipeline(delete_data, local: true) do
       {:ok, delete}
     else
       {:find_activity, _} -> {:error, :not_found}

From 14c667219334c492ae0549ad0f1e062085d7d412 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 16:49:41 +0200
Subject: [PATCH 13/76] AP C2S: Use common pipelin for deletes.

---
 lib/pleroma/web/activity_pub/activity_pub_controller.ex | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex
index d625530ec..e68d0763e 100644
--- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex
+++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex
@@ -414,7 +414,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
   defp handle_user_activity(%User{} = user, %{"type" => "Delete"} = params) do
     with %Object{} = object <- Object.normalize(params["object"]),
          true <- user.is_moderator || user.ap_id == object.data["actor"],
-         {:ok, delete} <- ActivityPub.delete(object) do
+         {:ok, delete_data, _} <- Builder.delete(user, object.data["id"]),
+         {:ok, delete, _} <- Pipeline.common_pipeline(delete_data, local: true) do
       {:ok, delete}
     else
       _ -> {:error, dgettext("errors", "Can't delete object")}

From 2c4844237f294d27f58737f9694f77b1cfcb10e7 Mon Sep 17 00:00:00 2001
From: Ivan Tashkinov <ivantashkinov@gmail.com>
Date: Thu, 30 Apr 2020 18:19:51 +0300
Subject: [PATCH 14/76] Refactoring of :if_func / :unless_func plug options
 (general availability). Added tests for Pleroma.Web.Plug.

---
 .../plugs/ensure_authenticated_plug.ex        | 17 +---
 lib/pleroma/plugs/federating_plug.ex          |  3 +
 .../activity_pub/activity_pub_controller.ex   |  2 +-
 lib/pleroma/web/feed/user_controller.ex       |  2 +-
 lib/pleroma/web/ostatus/ostatus_controller.ex |  2 +-
 .../web/static_fe/static_fe_controller.ex     |  2 +-
 lib/pleroma/web/web.ex                        | 10 +-
 test/plugs/ensure_authenticated_plug_test.exs |  4 +-
 test/web/plugs/plug_test.exs                  | 91 +++++++++++++++++++
 9 files changed, 109 insertions(+), 24 deletions(-)
 create mode 100644 test/web/plugs/plug_test.exs

diff --git a/lib/pleroma/plugs/ensure_authenticated_plug.ex b/lib/pleroma/plugs/ensure_authenticated_plug.ex
index 9c8f5597f..9d5176e2b 100644
--- a/lib/pleroma/plugs/ensure_authenticated_plug.ex
+++ b/lib/pleroma/plugs/ensure_authenticated_plug.ex
@@ -19,22 +19,7 @@ defmodule Pleroma.Plugs.EnsureAuthenticatedPlug do
     conn
   end
 
-  def perform(conn, options) do
-    perform =
-      cond do
-        options[:if_func] -> options[:if_func].()
-        options[:unless_func] -> !options[:unless_func].()
-        true -> true
-      end
-
-    if perform do
-      fail(conn)
-    else
-      conn
-    end
-  end
-
-  def fail(conn) do
+  def perform(conn, _) do
     conn
     |> render_error(:forbidden, "Invalid credentials.")
     |> halt()
diff --git a/lib/pleroma/plugs/federating_plug.ex b/lib/pleroma/plugs/federating_plug.ex
index 7d947339f..09038f3c6 100644
--- a/lib/pleroma/plugs/federating_plug.ex
+++ b/lib/pleroma/plugs/federating_plug.ex
@@ -19,6 +19,9 @@ defmodule Pleroma.Web.FederatingPlug do
 
   def federating?, do: Pleroma.Config.get([:instance, :federating])
 
+  # Definition for the use in :if_func / :unless_func plug options
+  def federating?(_conn), do: federating?()
+
   defp fail(conn) do
     conn
     |> put_status(404)
diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex
index d625530ec..a909516be 100644
--- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex
+++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex
@@ -34,7 +34,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
 
   plug(
     EnsureAuthenticatedPlug,
-    [unless_func: &FederatingPlug.federating?/0] when action not in @federating_only_actions
+    [unless_func: &FederatingPlug.federating?/1] when action not in @federating_only_actions
   )
 
   plug(
diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex
index e27f85929..1b72e23dc 100644
--- a/lib/pleroma/web/feed/user_controller.ex
+++ b/lib/pleroma/web/feed/user_controller.ex
@@ -27,7 +27,7 @@ defmodule Pleroma.Web.Feed.UserController do
       when format in ["json", "activity+json"] do
     with %{halted: false} = conn <-
            Pleroma.Plugs.EnsureAuthenticatedPlug.call(conn,
-             unless_func: &Pleroma.Web.FederatingPlug.federating?/0
+             unless_func: &Pleroma.Web.FederatingPlug.federating?/1
            ) do
       ActivityPubController.call(conn, :user)
     end
diff --git a/lib/pleroma/web/ostatus/ostatus_controller.ex b/lib/pleroma/web/ostatus/ostatus_controller.ex
index 6fd3cfce5..6971cd9f8 100644
--- a/lib/pleroma/web/ostatus/ostatus_controller.ex
+++ b/lib/pleroma/web/ostatus/ostatus_controller.ex
@@ -17,7 +17,7 @@ defmodule Pleroma.Web.OStatus.OStatusController do
   alias Pleroma.Web.Router
 
   plug(Pleroma.Plugs.EnsureAuthenticatedPlug,
-    unless_func: &Pleroma.Web.FederatingPlug.federating?/0
+    unless_func: &Pleroma.Web.FederatingPlug.federating?/1
   )
 
   plug(
diff --git a/lib/pleroma/web/static_fe/static_fe_controller.ex b/lib/pleroma/web/static_fe/static_fe_controller.ex
index 7a35238d7..c3efb6651 100644
--- a/lib/pleroma/web/static_fe/static_fe_controller.ex
+++ b/lib/pleroma/web/static_fe/static_fe_controller.ex
@@ -18,7 +18,7 @@ defmodule Pleroma.Web.StaticFE.StaticFEController do
   plug(:assign_id)
 
   plug(Pleroma.Plugs.EnsureAuthenticatedPlug,
-    unless_func: &Pleroma.Web.FederatingPlug.federating?/0
+    unless_func: &Pleroma.Web.FederatingPlug.federating?/1
   )
 
   @page_keys ["max_id", "min_id", "limit", "since_id", "order"]
diff --git a/lib/pleroma/web/web.ex b/lib/pleroma/web/web.ex
index 08e42a7e5..4f9281851 100644
--- a/lib/pleroma/web/web.ex
+++ b/lib/pleroma/web/web.ex
@@ -200,11 +200,17 @@ defmodule Pleroma.Web do
 
       @impl Plug
       @doc """
-      If marked as skipped, returns `conn`, otherwise calls `perform/2`.
+      Before-plug hook that
+        * ensures the plug is not skipped
+        * processes `:if_func` / `:unless_func` functional pre-run conditions
+        * adds plug to the list of called plugs and calls `perform/2` if checks are passed
+
       Note: multiple invocations of the same plug (with different or same options) are allowed.
       """
       def call(%Plug.Conn{} = conn, options) do
-        if PlugHelper.plug_skipped?(conn, __MODULE__) do
+        if PlugHelper.plug_skipped?(conn, __MODULE__) ||
+             (options[:if_func] && !options[:if_func].(conn)) ||
+             (options[:unless_func] && options[:unless_func].(conn)) do
           conn
         else
           conn =
diff --git a/test/plugs/ensure_authenticated_plug_test.exs b/test/plugs/ensure_authenticated_plug_test.exs
index 689fe757f..4e6142aab 100644
--- a/test/plugs/ensure_authenticated_plug_test.exs
+++ b/test/plugs/ensure_authenticated_plug_test.exs
@@ -27,8 +27,8 @@ defmodule Pleroma.Plugs.EnsureAuthenticatedPlugTest do
   describe "with :if_func / :unless_func options" do
     setup do
       %{
-        true_fn: fn -> true end,
-        false_fn: fn -> false end
+        true_fn: fn _conn -> true end,
+        false_fn: fn _conn -> false end
       }
     end
 
diff --git a/test/web/plugs/plug_test.exs b/test/web/plugs/plug_test.exs
new file mode 100644
index 000000000..943e484e7
--- /dev/null
+++ b/test/web/plugs/plug_test.exs
@@ -0,0 +1,91 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.PlugTest do
+  @moduledoc "Tests for the functionality added via `use Pleroma.Web, :plug`"
+
+  alias Pleroma.Plugs.ExpectAuthenticatedCheckPlug
+  alias Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug
+  alias Pleroma.Plugs.PlugHelper
+
+  import Mock
+
+  use Pleroma.Web.ConnCase
+
+  describe "when plug is skipped, " do
+    setup_with_mocks(
+      [
+        {ExpectPublicOrAuthenticatedCheckPlug, [:passthrough], []}
+      ],
+      %{conn: conn}
+    ) do
+      conn = ExpectPublicOrAuthenticatedCheckPlug.skip_plug(conn)
+      %{conn: conn}
+    end
+
+    test "it neither adds plug to called plugs list nor calls `perform/2`, " <>
+           "regardless of :if_func / :unless_func options",
+         %{conn: conn} do
+      for opts <- [%{}, %{if_func: fn _ -> true end}, %{unless_func: fn _ -> false end}] do
+        ret_conn = ExpectPublicOrAuthenticatedCheckPlug.call(conn, opts)
+
+        refute called(ExpectPublicOrAuthenticatedCheckPlug.perform(:_, :_))
+        refute PlugHelper.plug_called?(ret_conn, ExpectPublicOrAuthenticatedCheckPlug)
+      end
+    end
+  end
+
+  describe "when plug is NOT skipped, " do
+    setup_with_mocks([{ExpectAuthenticatedCheckPlug, [:passthrough], []}]) do
+      :ok
+    end
+
+    test "with no pre-run checks, adds plug to called plugs list and calls `perform/2`", %{
+      conn: conn
+    } do
+      ret_conn = ExpectAuthenticatedCheckPlug.call(conn, %{})
+
+      assert called(ExpectAuthenticatedCheckPlug.perform(ret_conn, :_))
+      assert PlugHelper.plug_called?(ret_conn, ExpectAuthenticatedCheckPlug)
+    end
+
+    test "when :if_func option is given, calls the plug only if provided function evals tru-ish",
+         %{conn: conn} do
+      ret_conn = ExpectAuthenticatedCheckPlug.call(conn, %{if_func: fn _ -> false end})
+
+      refute called(ExpectAuthenticatedCheckPlug.perform(:_, :_))
+      refute PlugHelper.plug_called?(ret_conn, ExpectAuthenticatedCheckPlug)
+
+      ret_conn = ExpectAuthenticatedCheckPlug.call(conn, %{if_func: fn _ -> true end})
+
+      assert called(ExpectAuthenticatedCheckPlug.perform(ret_conn, :_))
+      assert PlugHelper.plug_called?(ret_conn, ExpectAuthenticatedCheckPlug)
+    end
+
+    test "if :unless_func option is given, calls the plug only if provided function evals falsy",
+         %{conn: conn} do
+      ret_conn = ExpectAuthenticatedCheckPlug.call(conn, %{unless_func: fn _ -> true end})
+
+      refute called(ExpectAuthenticatedCheckPlug.perform(:_, :_))
+      refute PlugHelper.plug_called?(ret_conn, ExpectAuthenticatedCheckPlug)
+
+      ret_conn = ExpectAuthenticatedCheckPlug.call(conn, %{unless_func: fn _ -> false end})
+
+      assert called(ExpectAuthenticatedCheckPlug.perform(ret_conn, :_))
+      assert PlugHelper.plug_called?(ret_conn, ExpectAuthenticatedCheckPlug)
+    end
+
+    test "allows a plug to be called multiple times (even if it's in called plugs list)", %{
+      conn: conn
+    } do
+      conn = ExpectAuthenticatedCheckPlug.call(conn, %{an_option: :value1})
+      assert called(ExpectAuthenticatedCheckPlug.perform(conn, %{an_option: :value1}))
+
+      assert PlugHelper.plug_called?(conn, ExpectAuthenticatedCheckPlug)
+
+      conn = ExpectAuthenticatedCheckPlug.call(conn, %{an_option: :value2})
+      assert called(ExpectAuthenticatedCheckPlug.perform(conn, %{an_option: :value2}))
+    end
+  end
+end

From 143353432a562c49f4432e74a549321c5b43650d Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 17:52:29 +0200
Subject: [PATCH 15/76] StreamerTest: Separate deletion test.

---
 test/web/streamer/streamer_test.exs | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs
index 8b8d8af6c..3c0f240f5 100644
--- a/test/web/streamer/streamer_test.exs
+++ b/test/web/streamer/streamer_test.exs
@@ -210,6 +210,12 @@ defmodule Pleroma.Web.StreamerTest do
     Worker.push_to_socket(topics, "public", activity)
 
     Task.await(task)
+  end
+
+  test "works for deletions" do
+    user = insert(:user)
+    other_user = insert(:user)
+    {:ok, activity} = CommonAPI.post(other_user, %{"status" => "Test"})
 
     task =
       Task.async(fn ->

From 4500fdc04c528331f7289745dc08a34ce18d4da7 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 17:53:02 +0200
Subject: [PATCH 16/76] DeleteValidator: Add internal helper field after
 validation.

---
 .../object_validators/delete_validator.ex        | 16 ++++++++++++++++
 test/web/activity_pub/object_validator_test.exs  |  4 +++-
 2 files changed, 19 insertions(+), 1 deletion(-)

diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
index fa1713b50..951cc1414 100644
--- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
@@ -5,6 +5,7 @@
 defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do
   use Ecto.Schema
 
+  alias Pleroma.Activity
   alias Pleroma.Web.ActivityPub.ObjectValidators.Types
 
   import Ecto.Changeset
@@ -18,6 +19,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do
     field(:actor, Types.ObjectID)
     field(:to, Types.Recipients, default: [])
     field(:cc, Types.Recipients, default: [])
+    field(:deleted_activity_id)
     field(:object, Types.ObjectID)
   end
 
@@ -26,12 +28,26 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do
     |> cast(data, __schema__(:fields))
   end
 
+  def add_deleted_activity_id(cng) do
+    object =
+      cng
+      |> get_field(:object)
+
+    with %Activity{id: id} <- Activity.get_create_by_object_ap_id(object) do
+      cng
+      |> put_change(:deleted_activity_id, id)
+    else
+      _ -> cng
+    end
+  end
+
   def validate_data(cng) do
     cng
     |> validate_required([:id, :type, :actor, :to, :cc, :object])
     |> validate_inclusion(:type, ["Delete"])
     |> validate_same_domain()
     |> validate_object_or_user_presence()
+    |> add_deleted_activity_id()
   end
 
   def validate_same_domain(cng) do
diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs
index 83b21a9bc..9e0589722 100644
--- a/test/web/activity_pub/object_validator_test.exs
+++ b/test/web/activity_pub/object_validator_test.exs
@@ -21,7 +21,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do
     end
 
     test "it is valid for a post deletion", %{valid_post_delete: valid_post_delete} do
-      assert match?({:ok, _, _}, ObjectValidator.validate(valid_post_delete, []))
+      {:ok, valid_post_delete_u, _} = ObjectValidator.validate(valid_post_delete, [])
+
+      assert valid_post_delete_u["deleted_activity_id"]
     end
 
     test "it is valid for a user deletion", %{valid_user_delete: valid_user_delete} do

From c832d96fc9fc0b93befdf3a7064a8c9236e96d07 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 17:58:09 +0200
Subject: [PATCH 17/76] SideEffects: Stream out deletes.

---
 lib/pleroma/web/activity_pub/side_effects.ex | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex
index ef58fa399..d260e0069 100644
--- a/lib/pleroma/web/activity_pub/side_effects.ex
+++ b/lib/pleroma/web/activity_pub/side_effects.ex
@@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
   alias Pleroma.Object
   alias Pleroma.User
   alias Pleroma.Web.ActivityPub.Utils
+  alias Pleroma.Web.ActivityPub.ActivityPub
 
   def handle(object, meta \\ [])
 
@@ -40,9 +41,12 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
     result =
       case deleted_object do
         %Object{} ->
-          with {:ok, _, activity} <- Object.delete(deleted_object),
+          with {:ok, deleted_object, activity} <- Object.delete(deleted_object),
                %User{} = user <- User.get_cached_by_ap_id(deleted_object.data["actor"]) do
             User.remove_pinnned_activity(user, activity)
+
+            ActivityPub.stream_out(object)
+            ActivityPub.stream_out_participations(deleted_object, user)
             :ok
           end
 

From 315b773dd9fa185aef75b115efd90ac92113e6c3 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 17:58:31 +0200
Subject: [PATCH 18/76] ObjectValidator: Refactor.

---
 test/web/activity_pub/object_validator_test.exs | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs
index 9e0589722..1d3646487 100644
--- a/test/web/activity_pub/object_validator_test.exs
+++ b/test/web/activity_pub/object_validator_test.exs
@@ -21,9 +21,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do
     end
 
     test "it is valid for a post deletion", %{valid_post_delete: valid_post_delete} do
-      {:ok, valid_post_delete_u, _} = ObjectValidator.validate(valid_post_delete, [])
+      {:ok, valid_post_delete, _} = ObjectValidator.validate(valid_post_delete, [])
 
-      assert valid_post_delete_u["deleted_activity_id"]
+      assert valid_post_delete["deleted_activity_id"]
     end
 
     test "it is valid for a user deletion", %{valid_user_delete: valid_user_delete} do

From 3d0dc58e2e0a84cb46df5339596205f7baceb0a4 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 18:10:36 +0200
Subject: [PATCH 19/76] SideEffectsTest: Test streaming.

---
 test/web/activity_pub/side_effects_test.exs | 12 ++++++++++--
 1 file changed, 10 insertions(+), 2 deletions(-)

diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs
index fffe0ca38..f5c57d887 100644
--- a/test/web/activity_pub/side_effects_test.exs
+++ b/test/web/activity_pub/side_effects_test.exs
@@ -18,6 +18,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
   alias Pleroma.Web.CommonAPI
 
   import Pleroma.Factory
+  import Mock
 
   describe "delete objects" do
     setup do
@@ -33,9 +34,16 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
 
     test "it handles object deletions", %{delete: delete, post: post, object: object} do
       # In object deletions, the object is replaced by a tombstone and the
-      # create activity is deleted
+      # create activity is deleted.
 
-      {:ok, _delete, _} = SideEffects.handle(delete)
+      with_mock Pleroma.Web.ActivityPub.ActivityPub,
+        stream_out: fn _ -> nil end,
+        stream_out_participations: fn _, _ -> nil end do
+        {:ok, delete, _} = SideEffects.handle(delete)
+        user = User.get_cached_by_ap_id(object.data["actor"])
+        assert called(Pleroma.Web.ActivityPub.ActivityPub.stream_out(delete))
+        assert called(Pleroma.Web.ActivityPub.ActivityPub.stream_out_participations(object, user))
+      end
 
       object = Object.get_by_id(object.id)
       assert object.data["type"] == "Tombstone"

From ab60ee17765ee9d7dcb69cbf9c0630b97d4f5a93 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 18:19:39 +0200
Subject: [PATCH 20/76] SideEffects: On deletion, reduce the User note count.

---
 lib/pleroma/web/activity_pub/side_effects.ex | 2 ++
 test/web/activity_pub/side_effects_test.exs  | 8 ++++++--
 2 files changed, 8 insertions(+), 2 deletions(-)

diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex
index d260e0069..4fec3a797 100644
--- a/lib/pleroma/web/activity_pub/side_effects.ex
+++ b/lib/pleroma/web/activity_pub/side_effects.ex
@@ -34,6 +34,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
   # - Delete and unpins the create activity
   # - Replace object with Tombstone
   # - Set up notification
+  # - Reduce the user note count
   def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, meta) do
     deleted_object =
       Object.normalize(deleted_object, false) || User.get_cached_by_ap_id(deleted_object)
@@ -45,6 +46,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
                %User{} = user <- User.get_cached_by_ap_id(deleted_object.data["actor"]) do
             User.remove_pinnned_activity(user, activity)
 
+            {:ok, user} = ActivityPub.decrease_note_count_if_public(user, deleted_object)
             ActivityPub.stream_out(object)
             ActivityPub.stream_out_participations(deleted_object, user)
             :ok
diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs
index f5c57d887..06b3400d8 100644
--- a/test/web/activity_pub/side_effects_test.exs
+++ b/test/web/activity_pub/side_effects_test.exs
@@ -32,15 +32,16 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
       %{user: user, delete: delete, post: post, object: object, delete_user: delete_user}
     end
 
-    test "it handles object deletions", %{delete: delete, post: post, object: object} do
+    test "it handles object deletions", %{delete: delete, post: post, object: object, user: user} do
       # In object deletions, the object is replaced by a tombstone and the
       # create activity is deleted.
 
-      with_mock Pleroma.Web.ActivityPub.ActivityPub,
+      with_mock Pleroma.Web.ActivityPub.ActivityPub, [:passthrough],
         stream_out: fn _ -> nil end,
         stream_out_participations: fn _, _ -> nil end do
         {:ok, delete, _} = SideEffects.handle(delete)
         user = User.get_cached_by_ap_id(object.data["actor"])
+
         assert called(Pleroma.Web.ActivityPub.ActivityPub.stream_out(delete))
         assert called(Pleroma.Web.ActivityPub.ActivityPub.stream_out_participations(object, user))
       end
@@ -48,6 +49,9 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
       object = Object.get_by_id(object.id)
       assert object.data["type"] == "Tombstone"
       refute Activity.get_by_id(post.id)
+
+      user = User.get_by_id(user.id)
+      assert user.note_count == 0
     end
 
     test "it handles user deletions", %{delete_user: delete, user: user} do

From 60db58a1c6a2f139960d3db19cba08a496e6ccf4 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 18:38:37 +0200
Subject: [PATCH 21/76] Credo fixes.

---
 lib/pleroma/web/activity_pub/side_effects.ex | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex
index 4fec3a797..cf31de120 100644
--- a/lib/pleroma/web/activity_pub/side_effects.ex
+++ b/lib/pleroma/web/activity_pub/side_effects.ex
@@ -8,8 +8,8 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
   alias Pleroma.Notification
   alias Pleroma.Object
   alias Pleroma.User
-  alias Pleroma.Web.ActivityPub.Utils
   alias Pleroma.Web.ActivityPub.ActivityPub
+  alias Pleroma.Web.ActivityPub.Utils
 
   def handle(object, meta \\ [])
 

From 500f5ec14eb02cd1c5a07970a557756b590caab0 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 19:47:13 +0200
Subject: [PATCH 22/76] SideEffects: On deletion, reduce the reply count cache

---
 lib/pleroma/web/activity_pub/side_effects.ex |  6 ++++++
 test/web/activity_pub/side_effects_test.exs  | 22 ++++++++++++++------
 2 files changed, 22 insertions(+), 6 deletions(-)

diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex
index cf31de120..39b0f384b 100644
--- a/lib/pleroma/web/activity_pub/side_effects.ex
+++ b/lib/pleroma/web/activity_pub/side_effects.ex
@@ -35,6 +35,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
   # - Replace object with Tombstone
   # - Set up notification
   # - Reduce the user note count
+  # - TODO: Reduce the reply count
   def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, meta) do
     deleted_object =
       Object.normalize(deleted_object, false) || User.get_cached_by_ap_id(deleted_object)
@@ -47,6 +48,11 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
             User.remove_pinnned_activity(user, activity)
 
             {:ok, user} = ActivityPub.decrease_note_count_if_public(user, deleted_object)
+
+            if in_reply_to = deleted_object.data["inReplyTo"] do
+              Object.decrease_replies_count(in_reply_to)
+            end
+
             ActivityPub.stream_out(object)
             ActivityPub.stream_out_participations(deleted_object, user)
             :ok
diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs
index 06b3400d8..ce34eed4c 100644
--- a/test/web/activity_pub/side_effects_test.exs
+++ b/test/web/activity_pub/side_effects_test.exs
@@ -23,19 +23,25 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
   describe "delete objects" do
     setup do
       user = insert(:user)
-      {:ok, post} = CommonAPI.post(user, %{"status" => "hey"})
+      other_user = insert(:user)
+
+      {:ok, op} = CommonAPI.post(other_user, %{"status" => "big oof"})
+      {:ok, post} = CommonAPI.post(user, %{"status" => "hey", "in_reply_to_id" => op})
       object = Object.normalize(post)
       {:ok, delete_data, _meta} = Builder.delete(user, object.data["id"])
       {:ok, delete_user_data, _meta} = Builder.delete(user, user.ap_id)
       {:ok, delete, _meta} = ActivityPub.persist(delete_data, local: true)
       {:ok, delete_user, _meta} = ActivityPub.persist(delete_user_data, local: true)
-      %{user: user, delete: delete, post: post, object: object, delete_user: delete_user}
+      %{user: user, delete: delete, post: post, object: object, delete_user: delete_user, op: op}
     end
 
-    test "it handles object deletions", %{delete: delete, post: post, object: object, user: user} do
-      # In object deletions, the object is replaced by a tombstone and the
-      # create activity is deleted.
-
+    test "it handles object deletions", %{
+      delete: delete,
+      post: post,
+      object: object,
+      user: user,
+      op: op
+    } do
       with_mock Pleroma.Web.ActivityPub.ActivityPub, [:passthrough],
         stream_out: fn _ -> nil end,
         stream_out_participations: fn _, _ -> nil end do
@@ -52,6 +58,10 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
 
       user = User.get_by_id(user.id)
       assert user.note_count == 0
+
+      object = Object.normalize(op.data["object"], false)
+
+      assert object.data["repliesCount"] == 0
     end
 
     test "it handles user deletions", %{delete_user: delete, user: user} do

From 5da08c2b73f9ce1f369434fbd2c11092007e4910 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 19:53:30 +0200
Subject: [PATCH 23/76] SideEffects: Fix comment

---
 lib/pleroma/web/activity_pub/side_effects.ex |  2 +-
 test/user_test.exs                           | 28 +-------------------
 2 files changed, 2 insertions(+), 28 deletions(-)

diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex
index 39b0f384b..139e609f4 100644
--- a/lib/pleroma/web/activity_pub/side_effects.ex
+++ b/lib/pleroma/web/activity_pub/side_effects.ex
@@ -35,7 +35,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
   # - Replace object with Tombstone
   # - Set up notification
   # - Reduce the user note count
-  # - TODO: Reduce the reply count
+  # - Reduce the reply count
   def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, meta) do
     deleted_object =
       Object.normalize(deleted_object, false) || User.get_cached_by_ap_id(deleted_object)
diff --git a/test/user_test.exs b/test/user_test.exs
index 347c5be72..23afc605c 100644
--- a/test/user_test.exs
+++ b/test/user_test.exs
@@ -15,7 +15,6 @@ defmodule Pleroma.UserTest do
   use Pleroma.DataCase
   use Oban.Testing, repo: Pleroma.Repo
 
-  import Mock
   import Pleroma.Factory
   import ExUnit.CaptureLog
 
@@ -1131,7 +1130,7 @@ defmodule Pleroma.UserTest do
 
       User.delete_user_activities(user)
 
-      # TODO: Remove favorites, repeats, delete activities.
+      # TODO: Test removal favorites, repeats, delete activities.
       refute Activity.get_by_id(activity.id)
     end
 
@@ -1180,31 +1179,6 @@ defmodule Pleroma.UserTest do
       refute Activity.get_by_id(like_two.id)
       refute Activity.get_by_id(repeat.id)
     end
-
-    test_with_mock "it sends out User Delete activity",
-                   %{user: user},
-                   Pleroma.Web.ActivityPub.Publisher,
-                   [:passthrough],
-                   [] do
-      Pleroma.Config.put([:instance, :federating], true)
-
-      {:ok, follower} = User.get_or_fetch_by_ap_id("http://mastodon.example.org/users/admin")
-      {:ok, _} = User.follow(follower, user)
-
-      {:ok, job} = User.delete(user)
-      {:ok, _user} = ObanHelpers.perform(job)
-
-      assert ObanHelpers.member?(
-               %{
-                 "op" => "publish_one",
-                 "params" => %{
-                   "inbox" => "http://mastodon.example.org/inbox",
-                   "id" => "pleroma:fakeid"
-                 }
-               },
-               all_enqueued(worker: Pleroma.Workers.PublisherWorker)
-             )
-    end
   end
 
   test "get_public_key_for_ap_id fetches a user that's not in the db" do

From 3b443cbc1dd79b0450e17192aa51a00282b54d2e Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 20:08:25 +0200
Subject: [PATCH 24/76] User: Use common pipeline to delete user activities

---
 lib/pleroma/user.ex | 24 +++++++++++++-----------
 1 file changed, 13 insertions(+), 11 deletions(-)

diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex
index b451202b2..c780f99eb 100644
--- a/lib/pleroma/user.ex
+++ b/lib/pleroma/user.ex
@@ -29,7 +29,9 @@ defmodule Pleroma.User do
   alias Pleroma.UserRelationship
   alias Pleroma.Web
   alias Pleroma.Web.ActivityPub.ActivityPub
+  alias Pleroma.Web.ActivityPub.Builder
   alias Pleroma.Web.ActivityPub.ObjectValidators.Types
+  alias Pleroma.Web.ActivityPub.Pipeline
   alias Pleroma.Web.ActivityPub.Utils
   alias Pleroma.Web.CommonAPI
   alias Pleroma.Web.CommonAPI.Utils, as: CommonUtils
@@ -1427,8 +1429,6 @@ defmodule Pleroma.User do
 
   @spec perform(atom(), User.t()) :: {:ok, User.t()}
   def perform(:delete, %User{} = user) do
-    {:ok, _user} = ActivityPub.delete(user)
-
     # Remove all relationships
     user
     |> get_followers()
@@ -1531,21 +1531,23 @@ defmodule Pleroma.User do
     })
   end
 
-  def delete_user_activities(%User{ap_id: ap_id}) do
+  def delete_user_activities(%User{ap_id: ap_id} = user) do
     ap_id
     |> Activity.Queries.by_actor()
     |> RepoStreamer.chunk_stream(50)
-    |> Stream.each(fn activities -> Enum.each(activities, &delete_activity/1) end)
+    |> Stream.each(fn activities ->
+      Enum.each(activities, fn activity -> delete_activity(activity, user) end)
+    end)
     |> Stream.run()
   end
 
-  defp delete_activity(%{data: %{"type" => "Create"}} = activity) do
-    activity
-    |> Object.normalize()
-    |> ActivityPub.delete()
+  defp delete_activity(%{data: %{"type" => "Create", "object" => object}}, user) do
+    {:ok, delete_data, _} = Builder.delete(user, object)
+
+    Pipeline.common_pipeline(delete_data, local: true)
   end
 
-  defp delete_activity(%{data: %{"type" => "Like"}} = activity) do
+  defp delete_activity(%{data: %{"type" => "Like"}} = activity, _user) do
     object = Object.normalize(activity)
 
     activity.actor
@@ -1553,7 +1555,7 @@ defmodule Pleroma.User do
     |> ActivityPub.unlike(object)
   end
 
-  defp delete_activity(%{data: %{"type" => "Announce"}} = activity) do
+  defp delete_activity(%{data: %{"type" => "Announce"}} = activity, _user) do
     object = Object.normalize(activity)
 
     activity.actor
@@ -1561,7 +1563,7 @@ defmodule Pleroma.User do
     |> ActivityPub.unannounce(object)
   end
 
-  defp delete_activity(_activity), do: "Doing nothing"
+  defp delete_activity(_activity, _user), do: "Doing nothing"
 
   def html_filter_policy(%User{no_rich_text: true}) do
     Pleroma.HTML.Scrubber.TwitterText

From 999d639873b70f75c340dbac3360d25bca27a998 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 20:13:47 +0200
Subject: [PATCH 25/76] ActivityPub: Remove `delete` function.

This is handled by the common pipeline now.
---
 lib/pleroma/web/activity_pub/activity_pub.ex |  61 ---------
 test/web/activity_pub/activity_pub_test.exs  | 137 -------------------
 2 files changed, 198 deletions(-)

diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex
index 1f4a09370..51f002129 100644
--- a/lib/pleroma/web/activity_pub/activity_pub.ex
+++ b/lib/pleroma/web/activity_pub/activity_pub.ex
@@ -519,67 +519,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     end
   end
 
-  @spec delete(User.t() | Object.t(), keyword()) :: {:ok, User.t() | Object.t()} | {:error, any()}
-  def delete(entity, options \\ []) do
-    with {:ok, result} <- Repo.transaction(fn -> do_delete(entity, options) end) do
-      result
-    end
-  end
-
-  defp do_delete(%User{ap_id: ap_id, follower_address: follower_address} = user, _) do
-    with data <- %{
-           "to" => [follower_address],
-           "type" => "Delete",
-           "actor" => ap_id,
-           "object" => %{"type" => "Person", "id" => ap_id}
-         },
-         {:ok, activity} <- insert(data, true, true, true),
-         :ok <- maybe_federate(activity) do
-      {:ok, user}
-    end
-  end
-
-  defp do_delete(%Object{data: %{"id" => id, "actor" => actor}} = object, options) do
-    local = Keyword.get(options, :local, true)
-    activity_id = Keyword.get(options, :activity_id, nil)
-    actor = Keyword.get(options, :actor, actor)
-
-    user = User.get_cached_by_ap_id(actor)
-    to = (object.data["to"] || []) ++ (object.data["cc"] || [])
-
-    with create_activity <- Activity.get_create_by_object_ap_id(id),
-         data <-
-           %{
-             "type" => "Delete",
-             "actor" => actor,
-             "object" => id,
-             "to" => to,
-             "deleted_activity_id" => create_activity && create_activity.id
-           }
-           |> maybe_put("id", activity_id),
-         {:ok, activity} <- insert(data, local, false),
-         {:ok, object, _create_activity} <- Object.delete(object),
-         stream_out_participations(object, user),
-         _ <- decrease_replies_count_if_reply(object),
-         {:ok, _actor} <- decrease_note_count_if_public(user, object),
-         :ok <- maybe_federate(activity) do
-      {:ok, activity}
-    else
-      {:error, error} ->
-        Repo.rollback(error)
-    end
-  end
-
-  defp do_delete(%Object{data: %{"type" => "Tombstone", "id" => ap_id}}, _) do
-    activity =
-      ap_id
-      |> Activity.Queries.by_object_id()
-      |> Activity.Queries.by_type("Delete")
-      |> Repo.one()
-
-    {:ok, activity}
-  end
-
   @spec block(User.t(), User.t(), String.t() | nil, boolean()) ::
           {:ok, Activity.t()} | {:error, any()}
   def block(blocker, blocked, activity_id \\ nil, local \\ true) do
diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs
index edd7dfb22..b93ee708e 100644
--- a/test/web/activity_pub/activity_pub_test.exs
+++ b/test/web/activity_pub/activity_pub_test.exs
@@ -1331,143 +1331,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
     end
   end
 
-  describe "deletion" do
-    setup do: clear_config([:instance, :rewrite_policy])
-
-    test "it reverts deletion on error" do
-      note = insert(:note_activity)
-      object = Object.normalize(note)
-
-      with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do
-        assert {:error, :reverted} = ActivityPub.delete(object)
-      end
-
-      assert Repo.aggregate(Activity, :count, :id) == 1
-      assert Repo.get(Object, object.id) == object
-      assert Activity.get_by_id(note.id) == note
-    end
-
-    test "it creates a delete activity and deletes the original object" do
-      note = insert(:note_activity)
-      object = Object.normalize(note)
-      {:ok, delete} = ActivityPub.delete(object)
-
-      assert delete.data["type"] == "Delete"
-      assert delete.data["actor"] == note.data["actor"]
-      assert delete.data["object"] == object.data["id"]
-
-      assert Activity.get_by_id(delete.id) != nil
-
-      assert Repo.get(Object, object.id).data["type"] == "Tombstone"
-    end
-
-    test "it doesn't fail when an activity was already deleted" do
-      {:ok, delete} = insert(:note_activity) |> Object.normalize() |> ActivityPub.delete()
-
-      assert {:ok, ^delete} = delete |> Object.normalize() |> ActivityPub.delete()
-    end
-
-    test "decrements user note count only for public activities" do
-      user = insert(:user, note_count: 10)
-
-      {:ok, a1} =
-        CommonAPI.post(User.get_cached_by_id(user.id), %{
-          "status" => "yeah",
-          "visibility" => "public"
-        })
-
-      {:ok, a2} =
-        CommonAPI.post(User.get_cached_by_id(user.id), %{
-          "status" => "yeah",
-          "visibility" => "unlisted"
-        })
-
-      {:ok, a3} =
-        CommonAPI.post(User.get_cached_by_id(user.id), %{
-          "status" => "yeah",
-          "visibility" => "private"
-        })
-
-      {:ok, a4} =
-        CommonAPI.post(User.get_cached_by_id(user.id), %{
-          "status" => "yeah",
-          "visibility" => "direct"
-        })
-
-      {:ok, _} = Object.normalize(a1) |> ActivityPub.delete()
-      {:ok, _} = Object.normalize(a2) |> ActivityPub.delete()
-      {:ok, _} = Object.normalize(a3) |> ActivityPub.delete()
-      {:ok, _} = Object.normalize(a4) |> ActivityPub.delete()
-
-      user = User.get_cached_by_id(user.id)
-      assert user.note_count == 10
-    end
-
-    test "it creates a delete activity and checks that it is also sent to users mentioned by the deleted object" do
-      user = insert(:user)
-      note = insert(:note_activity)
-      object = Object.normalize(note)
-
-      {:ok, object} =
-        object
-        |> Object.change(%{
-          data: %{
-            "actor" => object.data["actor"],
-            "id" => object.data["id"],
-            "to" => [user.ap_id],
-            "type" => "Note"
-          }
-        })
-        |> Object.update_and_set_cache()
-
-      {:ok, delete} = ActivityPub.delete(object)
-
-      assert user.ap_id in delete.data["to"]
-    end
-
-    test "decreases reply count" do
-      user = insert(:user)
-      user2 = insert(:user)
-
-      {:ok, activity} = CommonAPI.post(user, %{"status" => "1", "visibility" => "public"})
-      reply_data = %{"status" => "1", "in_reply_to_status_id" => activity.id}
-      ap_id = activity.data["id"]
-
-      {:ok, public_reply} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "public"))
-      {:ok, unlisted_reply} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "unlisted"))
-      {:ok, private_reply} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "private"))
-      {:ok, direct_reply} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "direct"))
-
-      _ = CommonAPI.delete(direct_reply.id, user2)
-      assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
-      assert object.data["repliesCount"] == 2
-
-      _ = CommonAPI.delete(private_reply.id, user2)
-      assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
-      assert object.data["repliesCount"] == 2
-
-      _ = CommonAPI.delete(public_reply.id, user2)
-      assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
-      assert object.data["repliesCount"] == 1
-
-      _ = CommonAPI.delete(unlisted_reply.id, user2)
-      assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
-      assert object.data["repliesCount"] == 0
-    end
-
-    test "it passes delete activity through MRF before deleting the object" do
-      Pleroma.Config.put([:instance, :rewrite_policy], Pleroma.Web.ActivityPub.MRF.DropPolicy)
-
-      note = insert(:note_activity)
-      object = Object.normalize(note)
-
-      {:error, {:reject, _}} = ActivityPub.delete(object)
-
-      assert Activity.get_by_id(note.id)
-      assert Repo.get(Object, object.id).data["type"] == object.data["type"]
-    end
-  end
-
   describe "timeline post-processing" do
     test "it filters broken threads" do
       user1 = insert(:user)

From 32b8386edeec3e9b24123c3ccc81a22f1edd5a1c Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Thu, 30 Apr 2020 21:23:18 +0200
Subject: [PATCH 26/76] DeleteValidator: Don't federate local deletions of
 remote objects.

Closes #1497
---
 .../web/activity_pub/object_validator.ex      |  8 +-
 .../object_validators/delete_validator.ex     | 20 ++++-
 lib/pleroma/web/activity_pub/pipeline.ex      |  4 +-
 .../activity_pub/object_validator_test.exs    | 17 +++-
 test/web/common_api/common_api_test.exs       | 80 +++++++++++++++++++
 5 files changed, 119 insertions(+), 10 deletions(-)

diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex
index 32f606917..479f922f5 100644
--- a/lib/pleroma/web/activity_pub/object_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validator.ex
@@ -19,11 +19,11 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
   def validate(object, meta)
 
   def validate(%{"type" => "Delete"} = object, meta) do
-    with {:ok, object} <-
-           object
-           |> DeleteValidator.cast_and_validate()
-           |> Ecto.Changeset.apply_action(:insert) do
+    with cng <- DeleteValidator.cast_and_validate(object),
+         do_not_federate <- DeleteValidator.do_not_federate?(cng),
+         {:ok, object} <- Ecto.Changeset.apply_action(cng, :insert) do
       object = stringify_keys(object)
+      meta = Keyword.put(meta, :do_not_federate, do_not_federate)
       {:ok, object, meta}
     end
   end
diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
index 951cc1414..a2eff7b69 100644
--- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
@@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do
   use Ecto.Schema
 
   alias Pleroma.Activity
+  alias Pleroma.User
   alias Pleroma.Web.ActivityPub.ObjectValidators.Types
 
   import Ecto.Changeset
@@ -45,12 +46,17 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do
     cng
     |> validate_required([:id, :type, :actor, :to, :cc, :object])
     |> validate_inclusion(:type, ["Delete"])
-    |> validate_same_domain()
+    |> validate_actor_presence()
+    |> validate_deletion_rights()
     |> validate_object_or_user_presence()
     |> add_deleted_activity_id()
   end
 
-  def validate_same_domain(cng) do
+  def do_not_federate?(cng) do
+    !same_domain?(cng)
+  end
+
+  defp same_domain?(cng) do
     actor_domain =
       cng
       |> get_field(:actor)
@@ -63,11 +69,17 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do
       |> URI.parse()
       |> (& &1.host).()
 
-    if object_domain != actor_domain do
+    object_domain == actor_domain
+  end
+
+  def validate_deletion_rights(cng) do
+    actor = User.get_cached_by_ap_id(get_field(cng, :actor))
+
+    if User.superuser?(actor) || same_domain?(cng) do
       cng
-      |> add_error(:actor, "is not allowed to delete object")
     else
       cng
+      |> add_error(:actor, "is not allowed to delete object")
     end
   end
 
diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex
index 7ccee54c9..017e39abb 100644
--- a/lib/pleroma/web/activity_pub/pipeline.ex
+++ b/lib/pleroma/web/activity_pub/pipeline.ex
@@ -29,7 +29,9 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do
 
   defp maybe_federate(activity, meta) do
     with {:ok, local} <- Keyword.fetch(meta, :local) do
-      if local do
+      do_not_federate = meta[:do_not_federate]
+
+      if !do_not_federate && local do
         Federator.publish(activity)
         {:ok, :federated}
       else
diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs
index 1d3646487..412db09ff 100644
--- a/test/web/activity_pub/object_validator_test.exs
+++ b/test/web/activity_pub/object_validator_test.exs
@@ -52,9 +52,11 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do
 
     test "it's invalid if the actor of the object and the actor of delete are from different domains",
          %{valid_post_delete: valid_post_delete} do
+      valid_user = insert(:user)
+
       valid_other_actor =
         valid_post_delete
-        |> Map.put("actor", valid_post_delete["actor"] <> "1")
+        |> Map.put("actor", valid_user.ap_id)
 
       assert match?({:ok, _, _}, ObjectValidator.validate(valid_other_actor, []))
 
@@ -66,6 +68,19 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do
 
       assert {:actor, {"is not allowed to delete object", []}} in cng.errors
     end
+
+    test "it's valid if the actor of the object is a local superuser",
+         %{valid_post_delete: valid_post_delete} do
+      user =
+        insert(:user, local: true, is_moderator: true, ap_id: "https://gensokyo.2hu/users/raymoo")
+
+      valid_other_actor =
+        valid_post_delete
+        |> Map.put("actor", user.ap_id)
+
+      {:ok, _, meta} = ObjectValidator.validate(valid_other_actor, [])
+      assert meta[:do_not_federate]
+    end
   end
 
   describe "likes" do
diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs
index 1758662b0..32d91ce02 100644
--- a/test/web/common_api/common_api_test.exs
+++ b/test/web/common_api/common_api_test.exs
@@ -9,11 +9,13 @@ defmodule Pleroma.Web.CommonAPITest do
   alias Pleroma.Object
   alias Pleroma.User
   alias Pleroma.Web.ActivityPub.ActivityPub
+  alias Pleroma.Web.ActivityPub.Transmogrifier
   alias Pleroma.Web.ActivityPub.Visibility
   alias Pleroma.Web.AdminAPI.AccountView
   alias Pleroma.Web.CommonAPI
 
   import Pleroma.Factory
+  import Mock
 
   require Pleroma.Constants
 
@@ -21,6 +23,84 @@ defmodule Pleroma.Web.CommonAPITest do
   setup do: clear_config([:instance, :limit])
   setup do: clear_config([:instance, :max_pinned_statuses])
 
+  describe "deletion" do
+    test "it allows users to delete their posts" do
+      user = insert(:user)
+
+      {:ok, post} = CommonAPI.post(user, %{"status" => "namu amida butsu"})
+
+      with_mock Pleroma.Web.Federator,
+        publish: fn _ -> nil end do
+        assert {:ok, delete} = CommonAPI.delete(post.id, user)
+        assert delete.local
+        assert called(Pleroma.Web.Federator.publish(delete))
+      end
+
+      refute Activity.get_by_id(post.id)
+    end
+
+    test "it does not allow a user to delete their posts" do
+      user = insert(:user)
+      other_user = insert(:user)
+
+      {:ok, post} = CommonAPI.post(user, %{"status" => "namu amida butsu"})
+
+      assert {:error, "Could not delete"} = CommonAPI.delete(post.id, other_user)
+      assert Activity.get_by_id(post.id)
+    end
+
+    test "it allows moderators to delete other user's posts" do
+      user = insert(:user)
+      moderator = insert(:user, is_moderator: true)
+
+      {:ok, post} = CommonAPI.post(user, %{"status" => "namu amida butsu"})
+
+      assert {:ok, delete} = CommonAPI.delete(post.id, moderator)
+      assert delete.local
+
+      refute Activity.get_by_id(post.id)
+    end
+
+    test "it allows admins to delete other user's posts" do
+      user = insert(:user)
+      moderator = insert(:user, is_admin: true)
+
+      {:ok, post} = CommonAPI.post(user, %{"status" => "namu amida butsu"})
+
+      assert {:ok, delete} = CommonAPI.delete(post.id, moderator)
+      assert delete.local
+
+      refute Activity.get_by_id(post.id)
+    end
+
+    test "superusers deleting non-local posts won't federate the delete" do
+      # This is the user of the ingested activity
+      _user =
+        insert(:user,
+          local: false,
+          ap_id: "http://mastodon.example.org/users/admin",
+          last_refreshed_at: NaiveDateTime.utc_now()
+        )
+
+      moderator = insert(:user, is_admin: true)
+
+      data =
+        File.read!("test/fixtures/mastodon-post-activity.json")
+        |> Jason.decode!()
+
+      {:ok, post} = Transmogrifier.handle_incoming(data)
+
+      with_mock Pleroma.Web.Federator,
+        publish: fn _ -> nil end do
+        assert {:ok, delete} = CommonAPI.delete(post.id, moderator)
+        assert delete.local
+        refute called(Pleroma.Web.Federator.publish(:_))
+      end
+
+      refute Activity.get_by_id(post.id)
+    end
+  end
+
   test "favoriting race condition" do
     user = insert(:user)
     users_serial = insert_list(10, :user)

From ecf37b46d2c06c701da390eba65239984afe683f Mon Sep 17 00:00:00 2001
From: Alexander Strizhakov <alex.strizhakov@gmail.com>
Date: Fri, 1 May 2020 14:31:24 +0300
Subject: [PATCH 27/76] pagination fix for service users filters

---
 lib/pleroma/user/query.ex                     | 11 +++---
 .../web/admin_api/admin_api_controller.ex     | 29 +++-----------
 lib/pleroma/web/admin_api/search.ex           |  1 +
 .../admin_api/admin_api_controller_test.exs   | 38 ++++++++++++++++++-
 4 files changed, 49 insertions(+), 30 deletions(-)

diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex
index ac77aab71..3a3b04793 100644
--- a/lib/pleroma/user/query.ex
+++ b/lib/pleroma/user/query.ex
@@ -45,6 +45,7 @@ defmodule Pleroma.User.Query do
             is_admin: boolean(),
             is_moderator: boolean(),
             super_users: boolean(),
+            exclude_service_users: boolean(),
             followers: User.t(),
             friends: User.t(),
             recipients_from_activity: [String.t()],
@@ -88,6 +89,10 @@ defmodule Pleroma.User.Query do
     where(query, [u], ilike(field(u, ^key), ^"%#{value}%"))
   end
 
+  defp compose_query({:exclude_service_users, _}, query) do
+    where(query, [u], not like(u.ap_id, "%/relay") and not like(u.ap_id, "%/internal/fetch"))
+  end
+
   defp compose_query({key, value}, query)
        when key in @equal_criteria and not_empty_string(value) do
     where(query, [u], ^[{key, value}])
@@ -98,7 +103,7 @@ defmodule Pleroma.User.Query do
   end
 
   defp compose_query({:tags, tags}, query) when is_list(tags) and length(tags) > 0 do
-    Enum.reduce(tags, query, &prepare_tag_criteria/2)
+    where(query, [u], fragment("? && ?", u.tags, ^tags))
   end
 
   defp compose_query({:is_admin, _}, query) do
@@ -192,10 +197,6 @@ defmodule Pleroma.User.Query do
 
   defp compose_query(_unsupported_param, query), do: query
 
-  defp prepare_tag_criteria(tag, query) do
-    or_where(query, [u], fragment("? = any(?)", ^tag, u.tags))
-  end
-
   defp location_query(query, local) do
     where(query, [u], u.local == ^local)
     |> where([u], not is_nil(u.nickname))
diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex
index 816c11e01..bfcc81cb8 100644
--- a/lib/pleroma/web/admin_api/admin_api_controller.ex
+++ b/lib/pleroma/web/admin_api/admin_api_controller.ex
@@ -392,29 +392,12 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
       email: params["email"]
     }
 
-    with {:ok, users, count} <- Search.user(Map.merge(search_params, filters)),
-         {:ok, users, count} <- filter_service_users(users, count),
-         do:
-           conn
-           |> json(
-             AccountView.render("index.json",
-               users: users,
-               count: count,
-               page_size: page_size
-             )
-           )
-  end
-
-  defp filter_service_users(users, count) do
-    filtered_users = Enum.reject(users, &service_user?/1)
-    count = if Enum.any?(users, &service_user?/1), do: length(filtered_users), else: count
-
-    {:ok, filtered_users, count}
-  end
-
-  defp service_user?(user) do
-    String.match?(user.ap_id, ~r/.*\/relay$/) or
-      String.match?(user.ap_id, ~r/.*\/internal\/fetch$/)
+    with {:ok, users, count} <- Search.user(Map.merge(search_params, filters)) do
+      json(
+        conn,
+        AccountView.render("index.json", users: users, count: count, page_size: page_size)
+      )
+    end
   end
 
   @filters ~w(local external active deactivated is_admin is_moderator)
diff --git a/lib/pleroma/web/admin_api/search.ex b/lib/pleroma/web/admin_api/search.ex
index 29cea1f44..c28efadd5 100644
--- a/lib/pleroma/web/admin_api/search.ex
+++ b/lib/pleroma/web/admin_api/search.ex
@@ -21,6 +21,7 @@ defmodule Pleroma.Web.AdminAPI.Search do
     query =
       params
       |> Map.drop([:page, :page_size])
+      |> Map.put(:exclude_service_users, true)
       |> User.Query.build()
       |> order_by([u], u.nickname)
 
diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs
index f80dbf8dd..e3af01089 100644
--- a/test/web/admin_api/admin_api_controller_test.exs
+++ b/test/web/admin_api/admin_api_controller_test.exs
@@ -18,6 +18,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
   alias Pleroma.ReportNote
   alias Pleroma.Tests.ObanHelpers
   alias Pleroma.User
+  alias Pleroma.Web
   alias Pleroma.UserInviteToken
   alias Pleroma.Web.ActivityPub.Relay
   alias Pleroma.Web.CommonAPI
@@ -737,6 +738,39 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
              }
     end
 
+    test "pagination works correctly with service users", %{conn: conn} do
+      service1 = insert(:user, ap_id: Web.base_url() <> "/relay")
+      service2 = insert(:user, ap_id: Web.base_url() <> "/internal/fetch")
+      insert_list(25, :user)
+
+      assert %{"count" => 26, "page_size" => 10, "users" => users1} =
+               conn
+               |> get("/api/pleroma/admin/users?page=1&filters=", %{page_size: "10"})
+               |> json_response(200)
+
+      assert Enum.count(users1) == 10
+      assert service1 not in [users1]
+      assert service2 not in [users1]
+
+      assert %{"count" => 26, "page_size" => 10, "users" => users2} =
+               conn
+               |> get("/api/pleroma/admin/users?page=2&filters=", %{page_size: "10"})
+               |> json_response(200)
+
+      assert Enum.count(users2) == 10
+      assert service1 not in [users2]
+      assert service2 not in [users2]
+
+      assert %{"count" => 26, "page_size" => 10, "users" => users3} =
+               conn
+               |> get("/api/pleroma/admin/users?page=3&filters=", %{page_size: "10"})
+               |> json_response(200)
+
+      assert Enum.count(users3) == 6
+      assert service1 not in [users3]
+      assert service2 not in [users3]
+    end
+
     test "renders empty array for the second page", %{conn: conn} do
       insert(:user)
 
@@ -3526,7 +3560,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
     end
 
     test "success", %{conn: conn} do
-      base_url = Pleroma.Web.base_url()
+      base_url = Web.base_url()
       app_name = "Trusted app"
 
       response =
@@ -3547,7 +3581,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
     end
 
     test "with trusted", %{conn: conn} do
-      base_url = Pleroma.Web.base_url()
+      base_url = Web.base_url()
       app_name = "Trusted app"
 
       response =

From 5f42e6629d862f0a8dcbbd1527998685b6932d52 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Fri, 1 May 2020 13:34:47 +0200
Subject: [PATCH 28/76] DeleteValidator: Only allow deletion of certain types.

---
 .../object_validators/common_validations.ex   | 48 ++++++++++++-------
 .../object_validators/delete_validator.ex     | 12 ++++-
 lib/pleroma/web/activity_pub/side_effects.ex  |  1 +
 .../activity_pub/object_validator_test.exs    | 19 ++++++++
 4 files changed, 63 insertions(+), 17 deletions(-)

diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex
index d9a629a34..4e6ee2034 100644
--- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex
@@ -28,7 +28,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do
     end
   end
 
-  def validate_actor_presence(cng, field_name \\ :actor) do
+  def validate_actor_presence(cng, options \\ []) do
+    field_name = Keyword.get(options, :field_name, :actor)
+
     cng
     |> validate_change(field_name, fn field_name, actor ->
       if User.get_cached_by_ap_id(actor) do
@@ -39,25 +41,39 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do
     end)
   end
 
-  def validate_object_presence(cng, field_name \\ :object) do
+  def validate_object_presence(cng, options \\ []) do
+    field_name = Keyword.get(options, :field_name, :object)
+    allowed_types = Keyword.get(options, :allowed_types, false)
+
     cng
-    |> validate_change(field_name, fn field_name, object ->
-      if Object.get_cached_by_ap_id(object) do
-        []
-      else
-        [{field_name, "can't find object"}]
+    |> validate_change(field_name, fn field_name, object_id ->
+      object = Object.get_cached_by_ap_id(object_id)
+
+      cond do
+        !object ->
+          [{field_name, "can't find object"}]
+
+        object && allowed_types && object.data["type"] not in allowed_types ->
+          [{field_name, "object not in allowed types"}]
+
+        true ->
+          []
       end
     end)
   end
 
-  def validate_object_or_user_presence(cng, field_name \\ :object) do
-    cng
-    |> validate_change(field_name, fn field_name, object ->
-      if Object.get_cached_by_ap_id(object) || User.get_cached_by_ap_id(object) do
-        []
-      else
-        [{field_name, "can't find object"}]
-      end
-    end)
+  def validate_object_or_user_presence(cng, options \\ []) do
+    field_name = Keyword.get(options, :field_name, :object)
+    options = Keyword.put(options, :field_name, field_name)
+
+    actor_cng =
+      cng
+      |> validate_actor_presence(options)
+
+    object_cng =
+      cng
+      |> validate_object_presence(options)
+
+    if actor_cng.valid?, do: actor_cng, else: object_cng
   end
 end
diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
index a2eff7b69..256ac70b6 100644
--- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
@@ -42,13 +42,23 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do
     end
   end
 
+  @deletable_types ~w{
+    Answer
+    Article
+    Audio
+    Event
+    Note
+    Page
+    Question
+    Video
+  }
   def validate_data(cng) do
     cng
     |> validate_required([:id, :type, :actor, :to, :cc, :object])
     |> validate_inclusion(:type, ["Delete"])
     |> validate_actor_presence()
     |> validate_deletion_rights()
-    |> validate_object_or_user_presence()
+    |> validate_object_or_user_presence(allowed_types: @deletable_types)
     |> add_deleted_activity_id()
   end
 
diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex
index 139e609f4..52bd5179f 100644
--- a/lib/pleroma/web/activity_pub/side_effects.ex
+++ b/lib/pleroma/web/activity_pub/side_effects.ex
@@ -36,6 +36,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
   # - Set up notification
   # - Reduce the user note count
   # - Reduce the reply count
+  # - Stream out the activity
   def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, meta) do
     deleted_object =
       Object.normalize(deleted_object, false) || User.get_cached_by_ap_id(deleted_object)
diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs
index 412db09ff..7ab1c8ffb 100644
--- a/test/web/activity_pub/object_validator_test.exs
+++ b/test/web/activity_pub/object_validator_test.exs
@@ -1,6 +1,7 @@
 defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do
   use Pleroma.DataCase
 
+  alias Pleroma.Object
   alias Pleroma.Web.ActivityPub.Builder
   alias Pleroma.Web.ActivityPub.ObjectValidator
   alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator
@@ -26,6 +27,24 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do
       assert valid_post_delete["deleted_activity_id"]
     end
 
+    test "it is invalid if the object isn't in a list of certain types", %{
+      valid_post_delete: valid_post_delete
+    } do
+      object = Object.get_by_ap_id(valid_post_delete["object"])
+
+      data =
+        object.data
+        |> Map.put("type", "Like")
+
+      {:ok, _object} =
+        object
+        |> Ecto.Changeset.change(%{data: data})
+        |> Object.update_and_set_cache()
+
+      {:error, cng} = ObjectValidator.validate(valid_post_delete, [])
+      assert {:object, {"object not in allowed types", []}} in cng.errors
+    end
+
     test "it is valid for a user deletion", %{valid_user_delete: valid_user_delete} do
       assert match?({:ok, _, _}, ObjectValidator.validate(valid_user_delete, []))
     end

From 51f1dbf0a2bf6b61fdef0be56fd8f20a40827100 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Fri, 1 May 2020 14:05:25 +0200
Subject: [PATCH 29/76] User deletion mix task: Use common pipeline.

---
 lib/mix/tasks/pleroma/user.ex |  7 +++++--
 test/tasks/user_test.exs      | 18 +++++++++++++-----
 2 files changed, 18 insertions(+), 7 deletions(-)

diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex
index 40dd9bdc0..da140ac86 100644
--- a/lib/mix/tasks/pleroma/user.ex
+++ b/lib/mix/tasks/pleroma/user.ex
@@ -8,6 +8,8 @@ defmodule Mix.Tasks.Pleroma.User do
   alias Ecto.Changeset
   alias Pleroma.User
   alias Pleroma.UserInviteToken
+  alias Pleroma.Web.ActivityPub.Builder
+  alias Pleroma.Web.ActivityPub.Pipeline
 
   @shortdoc "Manages Pleroma users"
   @moduledoc File.read!("docs/administration/CLI_tasks/user.md")
@@ -96,8 +98,9 @@ defmodule Mix.Tasks.Pleroma.User do
   def run(["rm", nickname]) do
     start_pleroma()
 
-    with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do
-      User.perform(:delete, user)
+    with %User{local: true} = user <- User.get_cached_by_nickname(nickname),
+         {:ok, delete_data, _} <- Builder.delete(user, user.ap_id),
+         {:ok, _delete, _} <- Pipeline.common_pipeline(delete_data, local: true) do
       shell_info("User #{nickname} deleted.")
     else
       _ -> shell_error("No local user #{nickname}")
diff --git a/test/tasks/user_test.exs b/test/tasks/user_test.exs
index 8df835b56..ab56f07c1 100644
--- a/test/tasks/user_test.exs
+++ b/test/tasks/user_test.exs
@@ -4,14 +4,17 @@
 
 defmodule Mix.Tasks.Pleroma.UserTest do
   alias Pleroma.Repo
+  alias Pleroma.Tests.ObanHelpers
   alias Pleroma.User
   alias Pleroma.Web.OAuth.Authorization
   alias Pleroma.Web.OAuth.Token
 
   use Pleroma.DataCase
+  use Oban.Testing, repo: Pleroma.Repo
 
-  import Pleroma.Factory
   import ExUnit.CaptureIO
+  import Mock
+  import Pleroma.Factory
 
   setup_all do
     Mix.shell(Mix.Shell.Process)
@@ -87,12 +90,17 @@ defmodule Mix.Tasks.Pleroma.UserTest do
     test "user is deleted" do
       user = insert(:user)
 
-      Mix.Tasks.Pleroma.User.run(["rm", user.nickname])
+      with_mock Pleroma.Web.Federator,
+        publish: fn _ -> nil end do
+        Mix.Tasks.Pleroma.User.run(["rm", user.nickname])
+        ObanHelpers.perform_all()
 
-      assert_received {:mix_shell, :info, [message]}
-      assert message =~ " deleted"
+        assert_received {:mix_shell, :info, [message]}
+        assert message =~ " deleted"
+        refute User.get_by_nickname(user.nickname)
 
-      refute User.get_by_nickname(user.nickname)
+        assert called(Pleroma.Web.Federator.publish(:_))
+      end
     end
 
     test "no user to delete" do

From ebbd9c7f369f986b7a66f66eddab91537c490c79 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Fri, 1 May 2020 14:22:39 +0200
Subject: [PATCH 30/76] AdminAPIController: Refactor.

---
 lib/pleroma/web/admin_api/admin_api_controller.ex | 14 ++------------
 test/web/admin_api/admin_api_controller_test.exs  |  2 +-
 2 files changed, 3 insertions(+), 13 deletions(-)

diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex
index 816c11e01..c09584fd1 100644
--- a/lib/pleroma/web/admin_api/admin_api_controller.ex
+++ b/lib/pleroma/web/admin_api/admin_api_controller.ex
@@ -133,18 +133,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
 
   action_fallback(:errors)
 
-  def user_delete(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do
-    user = User.get_cached_by_nickname(nickname)
-    User.delete(user)
-
-    ModerationLog.insert_log(%{
-      actor: admin,
-      subject: [user],
-      action: "delete"
-    })
-
-    conn
-    |> json(nickname)
+  def user_delete(conn, %{"nickname" => nickname}) do
+    user_delete(conn, %{"nicknames" => [nickname]})
   end
 
   def user_delete(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do
diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs
index f80dbf8dd..c92715fab 100644
--- a/test/web/admin_api/admin_api_controller_test.exs
+++ b/test/web/admin_api/admin_api_controller_test.exs
@@ -156,7 +156,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
       assert ModerationLog.get_log_entry_message(log_entry) ==
                "@#{admin.nickname} deleted users: @#{user.nickname}"
 
-      assert json_response(conn, 200) == user.nickname
+      assert json_response(conn, 200) == [user.nickname]
     end
 
     test "multiple users", %{admin: admin, conn: conn} do

From 1ead5f49b8da941399fa2afadd40cd8beb8ccf8d Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Fri, 1 May 2020 14:30:39 +0200
Subject: [PATCH 31/76] AdminApiController: Use common pipeline for user
 deletion.

---
 .../web/admin_api/admin_api_controller.ex     | 13 +++++++--
 .../admin_api/admin_api_controller_test.exs   | 28 +++++++++++++------
 2 files changed, 30 insertions(+), 11 deletions(-)

diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex
index c09584fd1..9a12da027 100644
--- a/lib/pleroma/web/admin_api/admin_api_controller.ex
+++ b/lib/pleroma/web/admin_api/admin_api_controller.ex
@@ -17,6 +17,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
   alias Pleroma.User
   alias Pleroma.UserInviteToken
   alias Pleroma.Web.ActivityPub.ActivityPub
+  alias Pleroma.Web.ActivityPub.Builder
+  alias Pleroma.Web.ActivityPub.Pipeline
   alias Pleroma.Web.ActivityPub.Relay
   alias Pleroma.Web.ActivityPub.Utils
   alias Pleroma.Web.AdminAPI.AccountView
@@ -138,8 +140,15 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
   end
 
   def user_delete(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do
-    users = nicknames |> Enum.map(&User.get_cached_by_nickname/1)
-    User.delete(users)
+    users =
+      nicknames
+      |> Enum.map(&User.get_cached_by_nickname/1)
+
+    users
+    |> Enum.each(fn user ->
+      {:ok, delete_data, _} = Builder.delete(admin, user.ap_id)
+      Pipeline.common_pipeline(delete_data, local: true)
+    end)
 
     ModerationLog.insert_log(%{
       actor: admin,
diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs
index c92715fab..35001ab4a 100644
--- a/test/web/admin_api/admin_api_controller_test.exs
+++ b/test/web/admin_api/admin_api_controller_test.exs
@@ -6,8 +6,9 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
   use Pleroma.Web.ConnCase
   use Oban.Testing, repo: Pleroma.Repo
 
-  import Pleroma.Factory
   import ExUnit.CaptureLog
+  import Mock
+  import Pleroma.Factory
 
   alias Pleroma.Activity
   alias Pleroma.Config
@@ -146,17 +147,26 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
     test "single user", %{admin: admin, conn: conn} do
       user = insert(:user)
 
-      conn =
-        conn
-        |> put_req_header("accept", "application/json")
-        |> delete("/api/pleroma/admin/users?nickname=#{user.nickname}")
+      with_mock Pleroma.Web.Federator,
+        publish: fn _ -> nil end do
+        conn =
+          conn
+          |> put_req_header("accept", "application/json")
+          |> delete("/api/pleroma/admin/users?nickname=#{user.nickname}")
 
-      log_entry = Repo.one(ModerationLog)
+        ObanHelpers.perform_all()
 
-      assert ModerationLog.get_log_entry_message(log_entry) ==
-               "@#{admin.nickname} deleted users: @#{user.nickname}"
+        refute User.get_by_nickname(user.nickname)
 
-      assert json_response(conn, 200) == [user.nickname]
+        log_entry = Repo.one(ModerationLog)
+
+        assert ModerationLog.get_log_entry_message(log_entry) ==
+                 "@#{admin.nickname} deleted users: @#{user.nickname}"
+
+        assert json_response(conn, 200) == [user.nickname]
+
+        assert called(Pleroma.Web.Federator.publish(:_))
+      end
     end
 
     test "multiple users", %{admin: admin, conn: conn} do

From aea781cbd8fb43f906c6022a8d2e0bf896008203 Mon Sep 17 00:00:00 2001
From: Alexander Strizhakov <alex.strizhakov@gmail.com>
Date: Fri, 1 May 2020 16:31:05 +0300
Subject: [PATCH 32/76] credo fix

---
 test/web/admin_api/admin_api_controller_test.exs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs
index e3af01089..d798412e3 100644
--- a/test/web/admin_api/admin_api_controller_test.exs
+++ b/test/web/admin_api/admin_api_controller_test.exs
@@ -18,8 +18,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
   alias Pleroma.ReportNote
   alias Pleroma.Tests.ObanHelpers
   alias Pleroma.User
-  alias Pleroma.Web
   alias Pleroma.UserInviteToken
+  alias Pleroma.Web
   alias Pleroma.Web.ActivityPub.Relay
   alias Pleroma.Web.CommonAPI
   alias Pleroma.Web.MediaProxy

From f20a1a27ef93c494e671b67603b320249073e011 Mon Sep 17 00:00:00 2001
From: Lain Soykaf <lain@soykaf.club>
Date: Sun, 3 May 2020 12:19:01 +0200
Subject: [PATCH 33/76] DeleteValidator: Improve code readability

---
 .../activity_pub/object_validators/delete_validator.ex    | 8 +++-----
 1 file changed, 3 insertions(+), 5 deletions(-)

diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
index 256ac70b6..68ab08605 100644
--- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
@@ -67,19 +67,17 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do
   end
 
   defp same_domain?(cng) do
-    actor_domain =
+    actor_uri =
       cng
       |> get_field(:actor)
       |> URI.parse()
-      |> (& &1.host).()
 
-    object_domain =
+    object_uri =
       cng
       |> get_field(:object)
       |> URI.parse()
-      |> (& &1.host).()
 
-    object_domain == actor_domain
+    object_uri.host == actor_uri.host
   end
 
   def validate_deletion_rights(cng) do

From 4dfc617cdf1c2579f4f941dcd0fa5c728178df06 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Sun, 3 May 2020 12:51:28 +0200
Subject: [PATCH 34/76] Transmogrifier: Don't fetch actor that's guaranteed to
 be there.

---
 .../web/activity_pub/transmogrifier.ex        |  3 +-
 .../transmogrifier/delete_handling_test.exs   | 30 ++++---------------
 2 files changed, 7 insertions(+), 26 deletions(-)

diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex
index 855aab8d4..1e031a015 100644
--- a/lib/pleroma/web/activity_pub/transmogrifier.ex
+++ b/lib/pleroma/web/activity_pub/transmogrifier.ex
@@ -733,8 +733,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
         %{"type" => "Delete"} = data,
         _options
       ) do
-    with {:ok, %User{}} <- ObjectValidator.fetch_actor(data),
-         {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do
+    with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do
       {:ok, activity}
     end
   end
diff --git a/test/web/activity_pub/transmogrifier/delete_handling_test.exs b/test/web/activity_pub/transmogrifier/delete_handling_test.exs
index 64c908a05..c141e25bc 100644
--- a/test/web/activity_pub/transmogrifier/delete_handling_test.exs
+++ b/test/web/activity_pub/transmogrifier/delete_handling_test.exs
@@ -13,7 +13,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.DeleteHandlingTest do
   alias Pleroma.Web.ActivityPub.Transmogrifier
 
   import Pleroma.Factory
-  import ExUnit.CaptureLog
 
   setup_all do
     Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
@@ -27,22 +26,15 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.DeleteHandlingTest do
     data =
       File.read!("test/fixtures/mastodon-delete.json")
       |> Poison.decode!()
-
-    object =
-      data["object"]
-      |> Map.put("id", activity.data["object"])
-
-    data =
-      data
-      |> Map.put("object", object)
       |> Map.put("actor", deleting_user.ap_id)
+      |> put_in(["object", "id"], activity.data["object"])
 
     {:ok, %Activity{actor: actor, local: false, data: %{"id" => id}}} =
       Transmogrifier.handle_incoming(data)
 
     assert id == data["id"]
 
-    # We delete the Create activity because base our timelines on it.
+    # We delete the Create activity because we base our timelines on it.
     # This should be changed after we unify objects and activities
     refute Activity.get_by_id(activity.id)
     assert actor == deleting_user.ap_id
@@ -54,25 +46,15 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.DeleteHandlingTest do
 
   test "it fails for incoming deletes with spoofed origin" do
     activity = insert(:note_activity)
+    %{ap_id: ap_id} = insert(:user, ap_id: "https://gensokyo.2hu/users/raymoo")
 
     data =
       File.read!("test/fixtures/mastodon-delete.json")
       |> Poison.decode!()
+      |> Map.put("actor", ap_id)
+      |> put_in(["object", "id"], activity.data["object"])
 
-    object =
-      data["object"]
-      |> Map.put("id", activity.data["object"])
-
-    data =
-      data
-      |> Map.put("object", object)
-
-    assert capture_log(fn ->
-             {:error, _} = Transmogrifier.handle_incoming(data)
-           end) =~
-             "[error] Could not decode user at fetch http://mastodon.example.org/users/gargron, {:error, :nxdomain}"
-
-    assert Activity.get_by_id(activity.id)
+    assert match?({:error, _}, Transmogrifier.handle_incoming(data))
   end
 
   @tag capture_log: true

From 6c337489f4db28f78be940bef01ef3a80e279ffc Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Sun, 3 May 2020 13:01:19 +0200
Subject: [PATCH 35/76] Various testing fixes in relation to user deletion.

---
 test/web/activity_pub/side_effects_test.exs                   | 2 +-
 test/web/activity_pub/transmogrifier/delete_handling_test.exs | 2 +-
 test/web/admin_api/admin_api_controller_test.exs              | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs
index ce34eed4c..a9598d7b3 100644
--- a/test/web/activity_pub/side_effects_test.exs
+++ b/test/web/activity_pub/side_effects_test.exs
@@ -68,7 +68,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
       {:ok, _delete, _} = SideEffects.handle(delete)
       ObanHelpers.perform_all()
 
-      refute User.get_cached_by_ap_id(user.ap_id)
+      assert User.get_cached_by_ap_id(user.ap_id).deactivated
     end
   end
 
diff --git a/test/web/activity_pub/transmogrifier/delete_handling_test.exs b/test/web/activity_pub/transmogrifier/delete_handling_test.exs
index c141e25bc..f235a8e63 100644
--- a/test/web/activity_pub/transmogrifier/delete_handling_test.exs
+++ b/test/web/activity_pub/transmogrifier/delete_handling_test.exs
@@ -68,7 +68,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.DeleteHandlingTest do
     {:ok, _} = Transmogrifier.handle_incoming(data)
     ObanHelpers.perform_all()
 
-    refute User.get_cached_by_ap_id(ap_id)
+    assert User.get_cached_by_ap_id(ap_id).deactivated
   end
 
   test "it fails for incoming user deletes with spoofed origin" do
diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs
index bf054a12e..0daf29ffb 100644
--- a/test/web/admin_api/admin_api_controller_test.exs
+++ b/test/web/admin_api/admin_api_controller_test.exs
@@ -156,7 +156,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
 
         ObanHelpers.perform_all()
 
-        refute User.get_by_nickname(user.nickname)
+        assert User.get_by_nickname(user.nickname).deactivated
 
         log_entry = Repo.one(ModerationLog)
 

From 1974d0cc423efefcbdadd68442d0fbed8f3ee4ab Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Sun, 3 May 2020 13:02:57 +0200
Subject: [PATCH 36/76] DeleteValidator: The deleted activity id is an object
 id

---
 .../web/activity_pub/object_validators/delete_validator.ex      | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
index 68ab08605..e06de3dff 100644
--- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
@@ -20,7 +20,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do
     field(:actor, Types.ObjectID)
     field(:to, Types.Recipients, default: [])
     field(:cc, Types.Recipients, default: [])
-    field(:deleted_activity_id)
+    field(:deleted_activity_id, Types.ObjectID)
     field(:object, Types.ObjectID)
   end
 

From a7966f2080a0e9b3c2b35efa7ea647c1bdef2a2d Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Sun, 3 May 2020 13:48:01 +0200
Subject: [PATCH 37/76] Webfinger: Request account info with the acct scheme

---
 lib/pleroma/web/web_finger/web_finger.ex |  6 ++++--
 test/support/http_request_mock.ex        | 14 +++++++-------
 2 files changed, 11 insertions(+), 9 deletions(-)

diff --git a/lib/pleroma/web/web_finger/web_finger.ex b/lib/pleroma/web/web_finger/web_finger.ex
index 7ffd0e51b..442b25165 100644
--- a/lib/pleroma/web/web_finger/web_finger.ex
+++ b/lib/pleroma/web/web_finger/web_finger.ex
@@ -194,13 +194,15 @@ defmodule Pleroma.Web.WebFinger do
           URI.parse(account).host
       end
 
+    encoded_account = URI.encode("acct:#{account}")
+
     address =
       case find_lrdd_template(domain) do
         {:ok, template} ->
-          String.replace(template, "{uri}", URI.encode(account))
+          String.replace(template, "{uri}", encoded_account)
 
         _ ->
-          "https://#{domain}/.well-known/webfinger?resource=acct:#{account}"
+          "https://#{domain}/.well-known/webfinger?resource=#{encoded_account}"
       end
 
     with response <-
diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex
index 9624cb0f7..3a95e92da 100644
--- a/test/support/http_request_mock.ex
+++ b/test/support/http_request_mock.ex
@@ -211,7 +211,7 @@ defmodule HttpRequestMock do
   end
 
   def get(
-        "https://squeet.me/xrd/?uri=lain@squeet.me",
+        "https://squeet.me/xrd/?uri=acct:lain@squeet.me",
         _,
         _,
         [{"accept", "application/xrd+xml,application/jrd+json"}]
@@ -870,7 +870,7 @@ defmodule HttpRequestMock do
   end
 
   def get(
-        "https://social.heldscal.la/.well-known/webfinger?resource=shp@social.heldscal.la",
+        "https://social.heldscal.la/.well-known/webfinger?resource=acct:shp@social.heldscal.la",
         _,
         _,
         [{"accept", "application/xrd+xml,application/jrd+json"}]
@@ -883,7 +883,7 @@ defmodule HttpRequestMock do
   end
 
   def get(
-        "https://social.heldscal.la/.well-known/webfinger?resource=invalid_content@social.heldscal.la",
+        "https://social.heldscal.la/.well-known/webfinger?resource=acct:invalid_content@social.heldscal.la",
         _,
         _,
         [{"accept", "application/xrd+xml,application/jrd+json"}]
@@ -900,7 +900,7 @@ defmodule HttpRequestMock do
   end
 
   def get(
-        "http://framatube.org/main/xrd?uri=framasoft@framatube.org",
+        "http://framatube.org/main/xrd?uri=acct:framasoft@framatube.org",
         _,
         _,
         [{"accept", "application/xrd+xml,application/jrd+json"}]
@@ -959,7 +959,7 @@ defmodule HttpRequestMock do
   end
 
   def get(
-        "https://gerzilla.de/xrd/?uri=kaniini@gerzilla.de",
+        "https://gerzilla.de/xrd/?uri=acct:kaniini@gerzilla.de",
         _,
         _,
         [{"accept", "application/xrd+xml,application/jrd+json"}]
@@ -1155,7 +1155,7 @@ defmodule HttpRequestMock do
   end
 
   def get(
-        "https://zetsubou.xn--q9jyb4c/.well-known/webfinger?resource=lain@zetsubou.xn--q9jyb4c",
+        "https://zetsubou.xn--q9jyb4c/.well-known/webfinger?resource=acct:lain@zetsubou.xn--q9jyb4c",
         _,
         _,
         [{"accept", "application/xrd+xml,application/jrd+json"}]
@@ -1168,7 +1168,7 @@ defmodule HttpRequestMock do
   end
 
   def get(
-        "https://zetsubou.xn--q9jyb4c/.well-known/webfinger?resource=https://zetsubou.xn--q9jyb4c/users/lain",
+        "https://zetsubou.xn--q9jyb4c/.well-known/webfinger?resource=acct:https://zetsubou.xn--q9jyb4c/users/lain",
         _,
         _,
         [{"accept", "application/xrd+xml,application/jrd+json"}]

From 7dd47bee82c9f4a5e3b4ce6d74c5a22cac596b52 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Mon, 4 May 2020 12:22:31 +0200
Subject: [PATCH 38/76] Update changelog

---
 CHANGELOG.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 522285efe..92dd6f0ed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -37,6 +37,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - **Breaking**: SimplePolicy `:reject` and `:accept` allow deletions again
 - Fix follower/blocks import when nicknames starts with @
 - Filtering of push notifications on activities from blocked domains
+- Resolving Peertube accounts with Webfinger
 
 ## [unreleased-patch]
 ### Security

From d08c63500b5deca268ebc24833be4cb3279bdaaa Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Mon, 4 May 2020 20:16:18 +0400
Subject: [PATCH 39/76] Ignore unexpected query params

---
 lib/pleroma/web/api_spec/cast_and_validate.ex | 120 ++++++++++++++++++
 .../controllers/account_controller.ex         |   2 +-
 .../controllers/app_controller.ex             |   2 +-
 .../controllers/custom_emoji_controller.ex    |   2 +-
 .../controllers/domain_block_controller.ex    |   2 +-
 .../controllers/notification_controller.ex    |   2 +-
 .../controllers/report_controller.ex          |   2 +-
 7 files changed, 126 insertions(+), 6 deletions(-)
 create mode 100644 lib/pleroma/web/api_spec/cast_and_validate.ex

diff --git a/lib/pleroma/web/api_spec/cast_and_validate.ex b/lib/pleroma/web/api_spec/cast_and_validate.ex
new file mode 100644
index 000000000..f36cf7a55
--- /dev/null
+++ b/lib/pleroma/web/api_spec/cast_and_validate.ex
@@ -0,0 +1,120 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.CastAndValidate do
+  @moduledoc """
+  This plug is based on [`OpenApiSpex.Plug.CastAndValidate`]
+  (https://github.com/open-api-spex/open_api_spex/blob/master/lib/open_api_spex/plug/cast_and_validate.ex).
+  The main difference is ignoring unexpected query params
+  instead of throwing an error. Also, the default rendering
+  error module is `Pleroma.Web.ApiSpec.RenderError`.
+  """
+
+  @behaviour Plug
+
+  alias Plug.Conn
+
+  @impl Plug
+  def init(opts) do
+    opts
+    |> Map.new()
+    |> Map.put_new(:render_error, Pleroma.Web.ApiSpec.RenderError)
+  end
+
+  @impl Plug
+  def call(%{private: %{open_api_spex: private_data}} = conn, %{
+        operation_id: operation_id,
+        render_error: render_error
+      }) do
+    spec = private_data.spec
+    operation = private_data.operation_lookup[operation_id]
+
+    content_type =
+      case Conn.get_req_header(conn, "content-type") do
+        [header_value | _] ->
+          header_value
+          |> String.split(";")
+          |> List.first()
+
+        _ ->
+          nil
+      end
+
+    private_data = Map.put(private_data, :operation_id, operation_id)
+    conn = Conn.put_private(conn, :open_api_spex, private_data)
+
+    case cast_and_validate(spec, operation, conn, content_type) do
+      {:ok, conn} ->
+        conn
+
+      {:error, reason} ->
+        opts = render_error.init(reason)
+
+        conn
+        |> render_error.call(opts)
+        |> Plug.Conn.halt()
+    end
+  end
+
+  def call(
+        %{
+          private: %{
+            phoenix_controller: controller,
+            phoenix_action: action,
+            open_api_spex: private_data
+          }
+        } = conn,
+        opts
+      ) do
+    operation =
+      case private_data.operation_lookup[{controller, action}] do
+        nil ->
+          operation_id = controller.open_api_operation(action).operationId
+          operation = private_data.operation_lookup[operation_id]
+
+          operation_lookup =
+            private_data.operation_lookup
+            |> Map.put({controller, action}, operation)
+
+          OpenApiSpex.Plug.Cache.adapter().put(
+            private_data.spec_module,
+            {private_data.spec, operation_lookup}
+          )
+
+          operation
+
+        operation ->
+          operation
+      end
+
+    if operation.operationId do
+      call(conn, Map.put(opts, :operation_id, operation.operationId))
+    else
+      raise "operationId was not found in action API spec"
+    end
+  end
+
+  def call(conn, opts), do: OpenApiSpex.Plug.CastAndValidate.call(conn, opts)
+
+  defp cast_and_validate(spec, operation, conn, content_type) do
+    case OpenApiSpex.cast_and_validate(spec, operation, conn, content_type) do
+      {:ok, conn} ->
+        {:ok, conn}
+
+      # Remove unexpected query params and cast/validate again
+      {:error, errors} ->
+        query_params =
+          Enum.reduce(errors, conn.query_params, fn
+            %{reason: :unexpected_field, name: name, path: [name]}, params ->
+              Map.delete(params, name)
+
+            _, params ->
+              params
+          end)
+
+        conn = %Conn{conn | query_params: query_params}
+        OpenApiSpex.cast_and_validate(spec, operation, conn, content_type)
+    end
+  end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex
index 61b0e2f63..8458cbdd5 100644
--- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex
@@ -27,7 +27,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
   alias Pleroma.Web.OAuth.Token
   alias Pleroma.Web.TwitterAPI.TwitterAPI
 
-  plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError)
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
 
   plug(:skip_plug, [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] when action == :create)
 
diff --git a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex
index 408e11474..a516b6c20 100644
--- a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex
@@ -22,7 +22,7 @@ defmodule Pleroma.Web.MastodonAPI.AppController do
 
   plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :verify_credentials)
 
-  plug(OpenApiSpex.Plug.CastAndValidate)
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
 
   @local_mastodon_name "Mastodon-Local"
 
diff --git a/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex b/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex
index 000ad743f..c5f47c5df 100644
--- a/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex
@@ -5,7 +5,7 @@
 defmodule Pleroma.Web.MastodonAPI.CustomEmojiController do
   use Pleroma.Web, :controller
 
-  plug(OpenApiSpex.Plug.CastAndValidate)
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
 
   plug(
     :skip_plug,
diff --git a/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex
index c4fa383f2..825b231ab 100644
--- a/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex
@@ -8,7 +8,7 @@ defmodule Pleroma.Web.MastodonAPI.DomainBlockController do
   alias Pleroma.Plugs.OAuthScopesPlug
   alias Pleroma.User
 
-  plug(OpenApiSpex.Plug.CastAndValidate)
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
   defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.DomainBlockOperation
 
   plug(
diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex
index a14c86893..596b85617 100644
--- a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex
@@ -13,7 +13,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do
 
   @oauth_read_actions [:show, :index]
 
-  plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError)
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
 
   plug(
     OAuthScopesPlug,
diff --git a/lib/pleroma/web/mastodon_api/controllers/report_controller.ex b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex
index f65c5c62b..405167108 100644
--- a/lib/pleroma/web/mastodon_api/controllers/report_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex
@@ -9,7 +9,7 @@ defmodule Pleroma.Web.MastodonAPI.ReportController do
 
   action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
 
-  plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError)
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
   plug(OAuthScopesPlug, %{scopes: ["write:reports"]} when action == :create)
 
   defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ReportOperation

From bfbff7d82673d6128a18e73dcc91f70ee669c2ac Mon Sep 17 00:00:00 2001
From: minibikini <egor@kislitsyn.com>
Date: Mon, 4 May 2020 16:38:23 +0000
Subject: [PATCH 40/76] Apply suggestion to
 lib/pleroma/web/api_spec/cast_and_validate.ex

---
 lib/pleroma/web/api_spec/cast_and_validate.ex | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/lib/pleroma/web/api_spec/cast_and_validate.ex b/lib/pleroma/web/api_spec/cast_and_validate.ex
index f36cf7a55..cd02403c1 100644
--- a/lib/pleroma/web/api_spec/cast_and_validate.ex
+++ b/lib/pleroma/web/api_spec/cast_and_validate.ex
@@ -1,5 +1,6 @@
 # Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2019-2020 Moxley Stratton, Mike Buhot <https://github.com/open-api-spex/open_api_spex>, MPL-2.0
+# Copyright © 2020 Pleroma Authors <https://pleroma.social/>
 # SPDX-License-Identifier: AGPL-3.0-only
 
 defmodule Pleroma.Web.ApiSpec.CastAndValidate do

From 4b9ab67aa8bdf7fdf7390080932fee2e5879a5e4 Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Mon, 4 May 2020 21:46:25 +0400
Subject: [PATCH 41/76] Ignore unexpected ENUM values in query string

---
 lib/pleroma/web/api_spec/cast_and_validate.ex | 11 +++++++++++
 1 file changed, 11 insertions(+)

diff --git a/lib/pleroma/web/api_spec/cast_and_validate.ex b/lib/pleroma/web/api_spec/cast_and_validate.ex
index cd02403c1..b94517c52 100644
--- a/lib/pleroma/web/api_spec/cast_and_validate.ex
+++ b/lib/pleroma/web/api_spec/cast_and_validate.ex
@@ -110,6 +110,10 @@ defmodule Pleroma.Web.ApiSpec.CastAndValidate do
             %{reason: :unexpected_field, name: name, path: [name]}, params ->
               Map.delete(params, name)
 
+            %{reason: :invalid_enum, name: nil, path: path, value: value}, params ->
+              path = path |> Enum.reverse() |> tl() |> Enum.reverse() |> list_items_to_string()
+              update_in(params, path, &List.delete(&1, value))
+
             _, params ->
               params
           end)
@@ -118,4 +122,11 @@ defmodule Pleroma.Web.ApiSpec.CastAndValidate do
         OpenApiSpex.cast_and_validate(spec, operation, conn, content_type)
     end
   end
+
+  defp list_items_to_string(list) do
+    Enum.map(list, fn
+      i when is_atom(i) -> to_string(i)
+      i -> i
+    end)
+  end
 end

From f070b5569ca0eafdca79f1f3e3b6b5025f3f8fc9 Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Mon, 4 May 2020 22:33:05 +0400
Subject: [PATCH 42/76] Add a config option to enable strict validation

---
 config/config.exs                             |  2 ++
 lib/pleroma/web/api_spec/cast_and_validate.ex | 17 ++++++++++++-----
 2 files changed, 14 insertions(+), 5 deletions(-)

diff --git a/config/config.exs b/config/config.exs
index a6c6d6f99..ca9bbab64 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -653,6 +653,8 @@ config :pleroma, :restrict_unauthenticated,
   profiles: %{local: false, remote: false},
   activities: %{local: false, remote: false}
 
+config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false
+
 # Import environment specific config. This must remain at the bottom
 # of this file so it overrides the configuration defined above.
 import_config "#{Mix.env()}.exs"
diff --git a/lib/pleroma/web/api_spec/cast_and_validate.ex b/lib/pleroma/web/api_spec/cast_and_validate.ex
index b94517c52..bd9026237 100644
--- a/lib/pleroma/web/api_spec/cast_and_validate.ex
+++ b/lib/pleroma/web/api_spec/cast_and_validate.ex
@@ -7,9 +7,10 @@ defmodule Pleroma.Web.ApiSpec.CastAndValidate do
   @moduledoc """
   This plug is based on [`OpenApiSpex.Plug.CastAndValidate`]
   (https://github.com/open-api-spex/open_api_spex/blob/master/lib/open_api_spex/plug/cast_and_validate.ex).
-  The main difference is ignoring unexpected query params
-  instead of throwing an error. Also, the default rendering
-  error module is `Pleroma.Web.ApiSpec.RenderError`.
+  The main difference is ignoring unexpected query params instead of throwing
+  an error and a config option (`[Pleroma.Web.ApiSpec.CastAndValidate, :strict]`)
+  to disable this behavior. Also, the default rendering error module
+  is `Pleroma.Web.ApiSpec.RenderError`.
   """
 
   @behaviour Plug
@@ -45,7 +46,7 @@ defmodule Pleroma.Web.ApiSpec.CastAndValidate do
     private_data = Map.put(private_data, :operation_id, operation_id)
     conn = Conn.put_private(conn, :open_api_spex, private_data)
 
-    case cast_and_validate(spec, operation, conn, content_type) do
+    case cast_and_validate(spec, operation, conn, content_type, strict?()) do
       {:ok, conn} ->
         conn
 
@@ -98,7 +99,11 @@ defmodule Pleroma.Web.ApiSpec.CastAndValidate do
 
   def call(conn, opts), do: OpenApiSpex.Plug.CastAndValidate.call(conn, opts)
 
-  defp cast_and_validate(spec, operation, conn, content_type) do
+  defp cast_and_validate(spec, operation, conn, content_type, true = _strict) do
+    OpenApiSpex.cast_and_validate(spec, operation, conn, content_type)
+  end
+
+  defp cast_and_validate(spec, operation, conn, content_type, false = _strict) do
     case OpenApiSpex.cast_and_validate(spec, operation, conn, content_type) do
       {:ok, conn} ->
         {:ok, conn}
@@ -129,4 +134,6 @@ defmodule Pleroma.Web.ApiSpec.CastAndValidate do
       i -> i
     end)
   end
+
+  defp strict?, do: Pleroma.Config.get([__MODULE__, :strict], false)
 end

From e55fd530bc9a6ab42e475efe689e239963906928 Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Mon, 4 May 2020 22:33:34 +0400
Subject: [PATCH 43/76] Render better errors for ENUM validation

---
 lib/pleroma/web/api_spec/render_error.ex | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/lib/pleroma/web/api_spec/render_error.ex b/lib/pleroma/web/api_spec/render_error.ex
index b5877ca9c..d476b8ef3 100644
--- a/lib/pleroma/web/api_spec/render_error.ex
+++ b/lib/pleroma/web/api_spec/render_error.ex
@@ -17,6 +17,9 @@ defmodule Pleroma.Web.ApiSpec.RenderError do
   def call(conn, errors) do
     errors =
       Enum.map(errors, fn
+        %{name: nil, reason: :invalid_enum} = err ->
+          %OpenApiSpex.Cast.Error{err | name: err.value}
+
         %{name: nil} = err ->
           %OpenApiSpex.Cast.Error{err | name: List.last(err.path)}
 

From 1cb89aac1eef7711aa7950fe03e02e24bc665317 Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Mon, 4 May 2020 22:35:28 +0400
Subject: [PATCH 44/76] Enable strict validation mode in dev and test
 environments

---
 config/dev.exs  | 2 ++
 config/test.exs | 2 ++
 2 files changed, 4 insertions(+)

diff --git a/config/dev.exs b/config/dev.exs
index 7e1e3b4be..4faaeff5b 100644
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -52,6 +52,8 @@ config :pleroma, Pleroma.Repo,
   hostname: "localhost",
   pool_size: 10
 
+config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: true
+
 if File.exists?("./config/dev.secret.exs") do
   import_config "dev.secret.exs"
 else
diff --git a/config/test.exs b/config/test.exs
index 040e67e4a..cbf775109 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -96,6 +96,8 @@ config :pleroma, Pleroma.Emails.NewUsersDigestEmail, enabled: true
 
 config :pleroma, Pleroma.Plugs.RemoteIp, enabled: false
 
+config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: true
+
 if File.exists?("./config/test.secret.exs") do
   import_config "test.secret.exs"
 else

From bf0e41f0daa5809db53ed4a9130ade63952e8da0 Mon Sep 17 00:00:00 2001
From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me>
Date: Mon, 4 May 2020 23:32:53 +0200
Subject: [PATCH 45/76] Transmogrifier.set_sensitive/1: Keep sensitive set to
 true

---
 CHANGELOG.md                                  |  1 +
 .../web/activity_pub/transmogrifier.ex        |  4 ++++
 .../activity_pub_controller_test.exs          | 22 +++++++++++++------
 3 files changed, 20 insertions(+), 7 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 522285efe..cdb8a2080 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -47,6 +47,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Logger configuration through AdminFE
 - HTTP Basic Authentication permissions issue
 - ObjectAgePolicy didn't filter out old messages
+- Transmogrifier: Keep object sensitive settings for outgoing representation (AP C2S)
 
 ### Added
 - NodeInfo: ObjectAgePolicy settings to the `federation` list.
diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex
index 581e7040b..3a4d364e7 100644
--- a/lib/pleroma/web/activity_pub/transmogrifier.ex
+++ b/lib/pleroma/web/activity_pub/transmogrifier.ex
@@ -1195,6 +1195,10 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
     Map.put(object, "conversation", object["context"])
   end
 
+  def set_sensitive(%{"sensitive" => true} = object) do
+    object
+  end
+
   def set_sensitive(object) do
     tags = object["tag"] || []
     Map.put(object, "sensitive", "nsfw" in tags)
diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs
index a8f1f0e26..5c8d20ac4 100644
--- a/test/web/activity_pub/activity_pub_controller_test.exs
+++ b/test/web/activity_pub/activity_pub_controller_test.exs
@@ -820,21 +820,29 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
       activity: activity
     } do
       user = insert(:user)
+      conn = assign(conn, :user, user)
       object = Map.put(activity["object"], "sensitive", true)
       activity = Map.put(activity, "object", object)
 
-      result =
+      response =
         conn
-        |> assign(:user, user)
         |> put_req_header("content-type", "application/activity+json")
         |> post("/users/#{user.nickname}/outbox", activity)
         |> json_response(201)
 
-      assert Activity.get_by_ap_id(result["id"])
-      assert result["object"]
-      assert %Object{data: object} = Object.normalize(result["object"])
-      assert object["sensitive"] == activity["object"]["sensitive"]
-      assert object["content"] == activity["object"]["content"]
+      assert Activity.get_by_ap_id(response["id"])
+      assert response["object"]
+      assert %Object{data: response_object} = Object.normalize(response["object"])
+      assert response_object["sensitive"] == true
+      assert response_object["content"] == activity["object"]["content"]
+
+      representation =
+        conn
+        |> put_req_header("accept", "application/activity+json")
+        |> get(response["id"])
+        |> json_response(200)
+
+      assert representation["object"]["sensitive"] == true
     end
 
     test "it rejects an incoming activity with bogus type", %{conn: conn, activity: activity} do

From 8bed6ea922dbc1cfb8166fea6ce344d3618b3d52 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Tue, 5 May 2020 09:25:09 +0200
Subject: [PATCH 46/76] User, Webfinger: Remove OStatus vestiges

Mainly the `magic_key` field
---
 lib/pleroma/user.ex                           |  2 -
 lib/pleroma/web/web_finger/web_finger.ex      | 39 +------------------
 .../20200505072231_remove_magic_key_field.exs |  9 +++++
 test/web/web_finger/web_finger_test.exs       |  4 +-
 4 files changed, 12 insertions(+), 42 deletions(-)
 create mode 100644 priv/repo/migrations/20200505072231_remove_magic_key_field.exs

diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex
index 99358ddaf..2c343eb22 100644
--- a/lib/pleroma/user.ex
+++ b/lib/pleroma/user.ex
@@ -113,7 +113,6 @@ defmodule Pleroma.User do
     field(:is_admin, :boolean, default: false)
     field(:show_role, :boolean, default: true)
     field(:settings, :map, default: nil)
-    field(:magic_key, :string, default: nil)
     field(:uri, Types.Uri, default: nil)
     field(:hide_followers_count, :boolean, default: false)
     field(:hide_follows_count, :boolean, default: false)
@@ -387,7 +386,6 @@ defmodule Pleroma.User do
         :banner,
         :locked,
         :last_refreshed_at,
-        :magic_key,
         :uri,
         :follower_address,
         :following_address,
diff --git a/lib/pleroma/web/web_finger/web_finger.ex b/lib/pleroma/web/web_finger/web_finger.ex
index 7ffd0e51b..b26453828 100644
--- a/lib/pleroma/web/web_finger/web_finger.ex
+++ b/lib/pleroma/web/web_finger/web_finger.ex
@@ -86,53 +86,19 @@ defmodule Pleroma.Web.WebFinger do
     |> XmlBuilder.to_doc()
   end
 
-  defp get_magic_key("data:application/magic-public-key," <> magic_key) do
-    {:ok, magic_key}
-  end
-
-  defp get_magic_key(nil) do
-    Logger.debug("Undefined magic key.")
-    {:ok, nil}
-  end
-
-  defp get_magic_key(_) do
-    {:error, "Missing magic key data."}
-  end
-
   defp webfinger_from_xml(doc) do
-    with magic_key <- XML.string_from_xpath(~s{//Link[@rel="magic-public-key"]/@href}, doc),
-         {:ok, magic_key} <- get_magic_key(magic_key),
-         topic <-
-           XML.string_from_xpath(
-             ~s{//Link[@rel="http://schemas.google.com/g/2010#updates-from"]/@href},
-             doc
-           ),
-         subject <- XML.string_from_xpath("//Subject", doc),
-         subscribe_address <-
-           XML.string_from_xpath(
-             ~s{//Link[@rel="http://ostatus.org/schema/1.0/subscribe"]/@template},
-             doc
-           ),
+    with subject <- XML.string_from_xpath("//Subject", doc),
          ap_id <-
            XML.string_from_xpath(
              ~s{//Link[@rel="self" and @type="application/activity+json"]/@href},
              doc
            ) do
       data = %{
-        "magic_key" => magic_key,
-        "topic" => topic,
         "subject" => subject,
-        "subscribe_address" => subscribe_address,
         "ap_id" => ap_id
       }
 
       {:ok, data}
-    else
-      {:error, e} ->
-        {:error, e}
-
-      e ->
-        {:error, e}
     end
   end
 
@@ -146,9 +112,6 @@ defmodule Pleroma.Web.WebFinger do
           {"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", "self"} ->
             Map.put(data, "ap_id", link["href"])
 
-          {_, "http://ostatus.org/schema/1.0/subscribe"} ->
-            Map.put(data, "subscribe_address", link["template"])
-
           _ ->
             Logger.debug("Unhandled type: #{inspect(link["type"])}")
             data
diff --git a/priv/repo/migrations/20200505072231_remove_magic_key_field.exs b/priv/repo/migrations/20200505072231_remove_magic_key_field.exs
new file mode 100644
index 000000000..2635e671b
--- /dev/null
+++ b/priv/repo/migrations/20200505072231_remove_magic_key_field.exs
@@ -0,0 +1,9 @@
+defmodule Pleroma.Repo.Migrations.RemoveMagicKeyField do
+  use Ecto.Migration
+
+  def change do
+    alter table(:users) do
+      remove(:magic_key, :string)
+    end
+  end
+end
diff --git a/test/web/web_finger/web_finger_test.exs b/test/web/web_finger/web_finger_test.exs
index 4b4282727..ce17f83d6 100644
--- a/test/web/web_finger/web_finger_test.exs
+++ b/test/web/web_finger/web_finger_test.exs
@@ -67,10 +67,10 @@ defmodule Pleroma.Web.WebFingerTest do
       assert data["magic_key"] == nil
       assert data["salmon"] == nil
 
-      assert data["topic"] == "https://mstdn.jp/users/kPherox.atom"
+      assert data["topic"] == nil
       assert data["subject"] == "acct:kPherox@mstdn.jp"
       assert data["ap_id"] == "https://mstdn.jp/users/kPherox"
-      assert data["subscribe_address"] == "https://mstdn.jp/authorize_interaction?acct={uri}"
+      assert data["subscribe_address"] == nil
     end
 
     test "it works for friendica" do

From f897da21158796eb3962e50add312d62165160fc Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Tue, 5 May 2020 09:36:38 +0200
Subject: [PATCH 47/76] WebFinger: Add back in subscribe_address.

It's used for remote following.
---
 lib/pleroma/web/web_finger/web_finger.ex | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/lib/pleroma/web/web_finger/web_finger.ex b/lib/pleroma/web/web_finger/web_finger.ex
index b26453828..d0775fa28 100644
--- a/lib/pleroma/web/web_finger/web_finger.ex
+++ b/lib/pleroma/web/web_finger/web_finger.ex
@@ -88,6 +88,11 @@ defmodule Pleroma.Web.WebFinger do
 
   defp webfinger_from_xml(doc) do
     with subject <- XML.string_from_xpath("//Subject", doc),
+         subscribe_address <-
+           XML.string_from_xpath(
+             ~s{//Link[@rel="http://ostatus.org/schema/1.0/subscribe"]/@template},
+             doc
+           ),
          ap_id <-
            XML.string_from_xpath(
              ~s{//Link[@rel="self" and @type="application/activity+json"]/@href},
@@ -95,6 +100,7 @@ defmodule Pleroma.Web.WebFinger do
            ) do
       data = %{
         "subject" => subject,
+        "subscribe_address" => subscribe_address,
         "ap_id" => ap_id
       }
 

From 6a2905ccf08f89bd988b1bcd0788566930fbf17e Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Tue, 5 May 2020 09:55:33 +0200
Subject: [PATCH 48/76] WebFinger Test: Add back test.

---
 test/web/web_finger/web_finger_test.exs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test/web/web_finger/web_finger_test.exs b/test/web/web_finger/web_finger_test.exs
index ce17f83d6..f4884e0a2 100644
--- a/test/web/web_finger/web_finger_test.exs
+++ b/test/web/web_finger/web_finger_test.exs
@@ -70,7 +70,7 @@ defmodule Pleroma.Web.WebFingerTest do
       assert data["topic"] == nil
       assert data["subject"] == "acct:kPherox@mstdn.jp"
       assert data["ap_id"] == "https://mstdn.jp/users/kPherox"
-      assert data["subscribe_address"] == nil
+      assert data["subscribe_address"] == "https://mstdn.jp/authorize_interaction?acct={uri}"
     end
 
     test "it works for friendica" do

From f21f53829339115e9a6cc9066d09026345047b43 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Tue, 5 May 2020 10:38:59 +0200
Subject: [PATCH 49/76] LikeValidator: Add defaults for recipients back in.

---
 .../web/activity_pub/object_validators/like_validator.ex      | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex
index d835b052e..034f25492 100644
--- a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex
@@ -20,8 +20,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator do
     field(:object, Types.ObjectID)
     field(:actor, Types.ObjectID)
     field(:context, :string)
-    field(:to, Types.Recipients)
-    field(:cc, Types.Recipients)
+    field(:to, Types.Recipients, default: [])
+    field(:cc, Types.Recipients, default: [])
   end
 
   def cast_and_validate(data) do

From cc922e7d8ccbf22a0f7e0898a6ff4639123f0c7f Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Tue, 5 May 2020 14:44:29 +0400
Subject: [PATCH 50/76] Document configuration for
 Pleroma.Web.ApiSpec.CastAndValidate

---
 config/description.exs           | 14 ++++++++++++++
 docs/configuration/cheatsheet.md |  6 +++++-
 2 files changed, 19 insertions(+), 1 deletion(-)

diff --git a/config/description.exs b/config/description.exs
index 9d8e3b93c..72bb4d436 100644
--- a/config/description.exs
+++ b/config/description.exs
@@ -3194,5 +3194,19 @@ config :pleroma, :config_description, [
         ]
       }
     ]
+  },
+  %{
+    group: :pleroma,
+    key: Pleroma.Web.ApiSpec.CastAndValidate,
+    type: :group,
+    children: [
+      %{
+        key: :strict,
+        type: :boolean,
+        description:
+          "Enables strict input validation (useful in development, not recommended in production)",
+        suggestions: [false]
+      }
+    ]
   }
 ]
diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md
index 681ab6b93..705c4c15e 100644
--- a/docs/configuration/cheatsheet.md
+++ b/docs/configuration/cheatsheet.md
@@ -924,4 +924,8 @@ Restrict access for unauthenticated users to timelines (public and federate), us
   * `remote`
 * `activities` - statuses
   * `local`
-  * `remote`
\ No newline at end of file
+  * `remote`
+
+## Pleroma.Web.ApiSpec.CastAndValidate
+
+* `:strict` a boolean, enables strict input validation (useful in development, not recommended in production). Defaults to `false`.

From d20152700470c9b84a9404193ff08dd6d90b97a3 Mon Sep 17 00:00:00 2001
From: lain <lain@soykaf.club>
Date: Tue, 5 May 2020 11:17:44 +0000
Subject: [PATCH 51/76] Apply suggestion to
 lib/pleroma/web/web_finger/web_finger.ex

---
 lib/pleroma/web/web_finger/web_finger.ex | 34 +++++++++++-------------
 1 file changed, 16 insertions(+), 18 deletions(-)

diff --git a/lib/pleroma/web/web_finger/web_finger.ex b/lib/pleroma/web/web_finger/web_finger.ex
index d0775fa28..84ece1be2 100644
--- a/lib/pleroma/web/web_finger/web_finger.ex
+++ b/lib/pleroma/web/web_finger/web_finger.ex
@@ -87,25 +87,23 @@ defmodule Pleroma.Web.WebFinger do
   end
 
   defp webfinger_from_xml(doc) do
-    with subject <- XML.string_from_xpath("//Subject", doc),
-         subscribe_address <-
-           XML.string_from_xpath(
-             ~s{//Link[@rel="http://ostatus.org/schema/1.0/subscribe"]/@template},
-             doc
-           ),
-         ap_id <-
-           XML.string_from_xpath(
-             ~s{//Link[@rel="self" and @type="application/activity+json"]/@href},
-             doc
-           ) do
-      data = %{
-        "subject" => subject,
-        "subscribe_address" => subscribe_address,
-        "ap_id" => ap_id
-      }
+    subject = XML.string_from_xpath("//Subject", doc)
 
-      {:ok, data}
-    end
+    subscribe_address =
+      ~s{//Link[@rel="http://ostatus.org/schema/1.0/subscribe"]/@template}
+      |> XML.string_from_xpath(doc)
+
+    ap_id =
+      ~s{//Link[@rel="self" and @type="application/activity+json"]/@href}
+      |> XML.string_from_xpath(doc)
+
+    data = %{
+      "subject" => subject,
+      "subscribe_address" => subscribe_address,
+      "ap_id" => ap_id
+    }
+
+    {:ok, data}
   end
 
   defp webfinger_from_json(doc) do

From d861b0790a62767b31b8a85862fc249a4f8ca542 Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Tue, 5 May 2020 16:43:00 +0400
Subject: [PATCH 52/76] Add OpenAPI spec for SubscriptionController

---
 lib/pleroma/web/api_spec.ex                   |   7 +-
 .../operations/subscription_operation.ex      | 188 ++++++++++++++++++
 .../web/api_spec/schemas/push_subscription.ex |  66 ++++++
 .../controllers/subscription_controller.ex    |  12 +-
 lib/pleroma/web/push/subscription.ex          |  10 +-
 lib/pleroma/web/router.ex                     |   2 +-
 .../subscription_controller_test.exs          |  28 +--
 7 files changed, 288 insertions(+), 25 deletions(-)
 create mode 100644 lib/pleroma/web/api_spec/operations/subscription_operation.ex
 create mode 100644 lib/pleroma/web/api_spec/schemas/push_subscription.ex

diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex
index b3c1e3ea2..79fd5f871 100644
--- a/lib/pleroma/web/api_spec.ex
+++ b/lib/pleroma/web/api_spec.ex
@@ -39,7 +39,12 @@ defmodule Pleroma.Web.ApiSpec do
               password: %OpenApiSpex.OAuthFlow{
                 authorizationUrl: "/oauth/authorize",
                 tokenUrl: "/oauth/token",
-                scopes: %{"read" => "read", "write" => "write", "follow" => "follow"}
+                scopes: %{
+                  "read" => "read",
+                  "write" => "write",
+                  "follow" => "follow",
+                  "push" => "push"
+                }
               }
             }
           }
diff --git a/lib/pleroma/web/api_spec/operations/subscription_operation.ex b/lib/pleroma/web/api_spec/operations/subscription_operation.ex
new file mode 100644
index 000000000..663b8fa11
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/subscription_operation.ex
@@ -0,0 +1,188 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.SubscriptionOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Helpers
+  alias Pleroma.Web.ApiSpec.Schemas.ApiError
+  alias Pleroma.Web.ApiSpec.Schemas.PushSubscription
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def create_operation do
+    %Operation{
+      tags: ["Push Subscriptions"],
+      summary: "Subscribe to push notifications",
+      description:
+        "Add a Web Push API subscription to receive notifications. Each access token can have one push subscription. If you create a new subscription, the old subscription is deleted.",
+      operationId: "SubscriptionController.create",
+      security: [%{"oAuth" => ["push"]}],
+      requestBody: Helpers.request_body("Parameters", create_request(), required: true),
+      responses: %{
+        200 => Operation.response("Push Subscription", "application/json", PushSubscription),
+        400 => Operation.response("Error", "application/json", ApiError),
+        403 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  def show_operation do
+    %Operation{
+      tags: ["Push Subscriptions"],
+      summary: "Get current subscription",
+      description: "View the PushSubscription currently associated with this access token.",
+      operationId: "SubscriptionController.show",
+      security: [%{"oAuth" => ["push"]}],
+      responses: %{
+        200 => Operation.response("Push Subscription", "application/json", PushSubscription),
+        403 => Operation.response("Error", "application/json", ApiError),
+        404 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  def update_operation do
+    %Operation{
+      tags: ["Push Subscriptions"],
+      summary: "Change types of notifications",
+      description:
+        "Updates the current push subscription. Only the data part can be updated. To change fundamentals, a new subscription must be created instead.",
+      operationId: "SubscriptionController.update",
+      security: [%{"oAuth" => ["push"]}],
+      requestBody: Helpers.request_body("Parameters", update_request(), required: true),
+      responses: %{
+        200 => Operation.response("Push Subscription", "application/json", PushSubscription),
+        403 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  def delete_operation do
+    %Operation{
+      tags: ["Push Subscriptions"],
+      summary: "Remove current subscription",
+      description: "Removes the current Web Push API subscription.",
+      operationId: "SubscriptionController.delete",
+      security: [%{"oAuth" => ["push"]}],
+      responses: %{
+        200 => Operation.response("Empty object", "application/json", %Schema{type: :object}),
+        403 => Operation.response("Error", "application/json", ApiError),
+        404 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  defp create_request do
+    %Schema{
+      title: "SubscriptionCreateRequest",
+      description: "POST body for creating a push subscription",
+      type: :object,
+      properties: %{
+        subscription: %Schema{
+          type: :object,
+          properties: %{
+            endpoint: %Schema{
+              type: :string,
+              description: "Endpoint URL that is called when a notification event occurs."
+            },
+            keys: %Schema{
+              type: :object,
+              properties: %{
+                p256dh: %Schema{
+                  type: :string,
+                  description:
+                    "User agent public key. Base64 encoded string of public key of ECDH key using `prime256v1` curve."
+                },
+                auth: %Schema{
+                  type: :string,
+                  description: "Auth secret. Base64 encoded string of 16 bytes of random data."
+                }
+              },
+              required: [:p256dh, :auth]
+            }
+          },
+          required: [:endpoint, :keys]
+        },
+        data: %Schema{
+          type: :object,
+          properties: %{
+            alerts: %Schema{
+              type: :object,
+              properties: %{
+                follow: %Schema{type: :boolean, description: "Receive follow notifications?"},
+                favourite: %Schema{
+                  type: :boolean,
+                  description: "Receive favourite notifications?"
+                },
+                reblog: %Schema{type: :boolean, description: "Receive reblog notifications?"},
+                mention: %Schema{type: :boolean, description: "Receive mention notifications?"},
+                poll: %Schema{type: :boolean, description: "Receive poll notifications?"}
+              }
+            }
+          }
+        }
+      },
+      required: [:subscription],
+      example: %{
+        "subscription" => %{
+          "endpoint" => "https://example.com/example/1234",
+          "keys" => %{
+            "auth" => "8eDyX_uCN0XRhSbY5hs7Hg==",
+            "p256dh" =>
+              "BCIWgsnyXDv1VkhqL2P7YRBvdeuDnlwAPT2guNhdIoW3IP7GmHh1SMKPLxRf7x8vJy6ZFK3ol2ohgn_-0yP7QQA="
+          }
+        },
+        "data" => %{
+          "alerts" => %{
+            "follow" => true,
+            "mention" => true,
+            "poll" => false
+          }
+        }
+      }
+    }
+  end
+
+  defp update_request do
+    %Schema{
+      title: "SubscriptionUpdateRequest",
+      type: :object,
+      properties: %{
+        data: %Schema{
+          type: :object,
+          properties: %{
+            alerts: %Schema{
+              type: :object,
+              properties: %{
+                follow: %Schema{type: :boolean, description: "Receive follow notifications?"},
+                favourite: %Schema{
+                  type: :boolean,
+                  description: "Receive favourite notifications?"
+                },
+                reblog: %Schema{type: :boolean, description: "Receive reblog notifications?"},
+                mention: %Schema{type: :boolean, description: "Receive mention notifications?"},
+                poll: %Schema{type: :boolean, description: "Receive poll notifications?"}
+              }
+            }
+          }
+        }
+      },
+      example: %{
+        "data" => %{
+          "alerts" => %{
+            "follow" => true,
+            "favourite" => true,
+            "reblog" => true,
+            "mention" => true,
+            "poll" => true
+          }
+        }
+      }
+    }
+  end
+end
diff --git a/lib/pleroma/web/api_spec/schemas/push_subscription.ex b/lib/pleroma/web/api_spec/schemas/push_subscription.ex
new file mode 100644
index 000000000..cc91b95b8
--- /dev/null
+++ b/lib/pleroma/web/api_spec/schemas/push_subscription.ex
@@ -0,0 +1,66 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Schemas.PushSubscription do
+  alias OpenApiSpex.Schema
+
+  require OpenApiSpex
+
+  OpenApiSpex.schema(%{
+    title: "PushSubscription",
+    description: "Response schema for a push subscription",
+    type: :object,
+    properties: %{
+      id: %Schema{
+        anyOf: [%Schema{type: :string}, %Schema{type: :integer}],
+        description: "The id of the push subscription in the database."
+      },
+      endpoint: %Schema{type: :string, description: "Where push alerts will be sent to."},
+      server_key: %Schema{type: :string, description: "The streaming server's VAPID key."},
+      alerts: %Schema{
+        type: :object,
+        description: "Which alerts should be delivered to the endpoint.",
+        properties: %{
+          follow: %Schema{
+            type: :boolean,
+            description: "Receive a push notification when someone has followed you?"
+          },
+          favourite: %Schema{
+            type: :boolean,
+            description:
+              "Receive a push notification when a status you created has been favourited by someone else?"
+          },
+          reblog: %Schema{
+            type: :boolean,
+            description:
+              "Receive a push notification when a status you created has been boosted by someone else?"
+          },
+          mention: %Schema{
+            type: :boolean,
+            description:
+              "Receive a push notification when someone else has mentioned you in a status?"
+          },
+          poll: %Schema{
+            type: :boolean,
+            description:
+              "Receive a push notification when a poll you voted in or created has ended? "
+          }
+        }
+      }
+    },
+    example: %{
+      "id" => "328_183",
+      "endpoint" => "https://yourdomain.example/listener",
+      "alerts" => %{
+        "follow" => true,
+        "favourite" => true,
+        "reblog" => true,
+        "mention" => true,
+        "poll" => true
+      },
+      "server_key" =>
+        "BCk-QqERU0q-CfYZjcuB6lnyyOYfJ2AifKqfeGIm7Z-HiTU5T9eTG5GxVA0_OH5mMlI4UkkDTpaZwozy0TzdZ2M="
+    }
+  })
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex b/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex
index d184ea1d0..34eac97c5 100644
--- a/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex
@@ -11,14 +11,16 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionController do
 
   action_fallback(:errors)
 
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
+  plug(:restrict_push_enabled)
   plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["push"]})
 
-  plug(:restrict_push_enabled)
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.SubscriptionOperation
 
   # Creates PushSubscription
   # POST /api/v1/push/subscription
   #
-  def create(%{assigns: %{user: user, token: token}} = conn, params) do
+  def create(%{assigns: %{user: user, token: token}, body_params: params} = conn, _) do
     with {:ok, _} <- Subscription.delete_if_exists(user, token),
          {:ok, subscription} <- Subscription.create(user, token, params) do
       render(conn, "show.json", subscription: subscription)
@@ -28,7 +30,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionController do
   # Gets PushSubscription
   # GET /api/v1/push/subscription
   #
-  def get(%{assigns: %{user: user, token: token}} = conn, _params) do
+  def show(%{assigns: %{user: user, token: token}} = conn, _params) do
     with {:ok, subscription} <- Subscription.get(user, token) do
       render(conn, "show.json", subscription: subscription)
     end
@@ -37,7 +39,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionController do
   # Updates PushSubscription
   # PUT /api/v1/push/subscription
   #
-  def update(%{assigns: %{user: user, token: token}} = conn, params) do
+  def update(%{assigns: %{user: user, token: token}, body_params: params} = conn, _) do
     with {:ok, subscription} <- Subscription.update(user, token, params) do
       render(conn, "show.json", subscription: subscription)
     end
@@ -66,7 +68,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionController do
   def errors(conn, {:error, :not_found}) do
     conn
     |> put_status(:not_found)
-    |> json(dgettext("errors", "Not found"))
+    |> json(%{error: dgettext("errors", "Record not found")})
   end
 
   def errors(conn, _) do
diff --git a/lib/pleroma/web/push/subscription.ex b/lib/pleroma/web/push/subscription.ex
index b99b0c5fb..3e401a490 100644
--- a/lib/pleroma/web/push/subscription.ex
+++ b/lib/pleroma/web/push/subscription.ex
@@ -25,9 +25,9 @@ defmodule Pleroma.Web.Push.Subscription do
     timestamps()
   end
 
-  @supported_alert_types ~w[follow favourite mention reblog]
+  @supported_alert_types ~w[follow favourite mention reblog]a
 
-  defp alerts(%{"data" => %{"alerts" => alerts}}) do
+  defp alerts(%{data: %{alerts: alerts}}) do
     alerts = Map.take(alerts, @supported_alert_types)
     %{"alerts" => alerts}
   end
@@ -44,9 +44,9 @@ defmodule Pleroma.Web.Push.Subscription do
         %User{} = user,
         %Token{} = token,
         %{
-          "subscription" => %{
-            "endpoint" => endpoint,
-            "keys" => %{"auth" => key_auth, "p256dh" => key_p256dh}
+          subscription: %{
+            endpoint: endpoint,
+            keys: %{auth: key_auth, p256dh: key_p256dh}
           }
         } = params
       ) do
diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex
index 5b00243e9..eda8320ea 100644
--- a/lib/pleroma/web/router.ex
+++ b/lib/pleroma/web/router.ex
@@ -426,7 +426,7 @@ defmodule Pleroma.Web.Router do
     post("/statuses/:id/unmute", StatusController, :unmute_conversation)
 
     post("/push/subscription", SubscriptionController, :create)
-    get("/push/subscription", SubscriptionController, :get)
+    get("/push/subscription", SubscriptionController, :show)
     put("/push/subscription", SubscriptionController, :update)
     delete("/push/subscription", SubscriptionController, :delete)
 
diff --git a/test/web/mastodon_api/controllers/subscription_controller_test.exs b/test/web/mastodon_api/controllers/subscription_controller_test.exs
index 5682498c0..4aa260663 100644
--- a/test/web/mastodon_api/controllers/subscription_controller_test.exs
+++ b/test/web/mastodon_api/controllers/subscription_controller_test.exs
@@ -6,6 +6,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
   use Pleroma.Web.ConnCase
 
   import Pleroma.Factory
+
   alias Pleroma.Web.Push
   alias Pleroma.Web.Push.Subscription
 
@@ -27,6 +28,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
       build_conn()
       |> assign(:user, user)
       |> assign(:token, token)
+      |> put_req_header("content-type", "application/json")
 
     %{conn: conn, user: user, token: token}
   end
@@ -47,8 +49,8 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
     test "returns error when push disabled ", %{conn: conn} do
       assert_error_when_disable_push do
         conn
-        |> post("/api/v1/push/subscription", %{})
-        |> json_response(403)
+        |> post("/api/v1/push/subscription", %{subscription: @sub})
+        |> json_response_and_validate_schema(403)
       end
     end
 
@@ -59,7 +61,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
           "data" => %{"alerts" => %{"mention" => true, "test" => true}},
           "subscription" => @sub
         })
-        |> json_response(200)
+        |> json_response_and_validate_schema(200)
 
       [subscription] = Pleroma.Repo.all(Subscription)
 
@@ -77,7 +79,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
       assert_error_when_disable_push do
         conn
         |> get("/api/v1/push/subscription", %{})
-        |> json_response(403)
+        |> json_response_and_validate_schema(403)
       end
     end
 
@@ -85,9 +87,9 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
       res =
         conn
         |> get("/api/v1/push/subscription", %{})
-        |> json_response(404)
+        |> json_response_and_validate_schema(404)
 
-      assert "Not found" == res
+      assert %{"error" => "Record not found"} == res
     end
 
     test "returns a user subsciption", %{conn: conn, user: user, token: token} do
@@ -101,7 +103,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
       res =
         conn
         |> get("/api/v1/push/subscription", %{})
-        |> json_response(200)
+        |> json_response_and_validate_schema(200)
 
       expect = %{
         "alerts" => %{"mention" => true},
@@ -130,7 +132,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
       assert_error_when_disable_push do
         conn
         |> put("/api/v1/push/subscription", %{data: %{"alerts" => %{"mention" => false}}})
-        |> json_response(403)
+        |> json_response_and_validate_schema(403)
       end
     end
 
@@ -140,7 +142,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
         |> put("/api/v1/push/subscription", %{
           data: %{"alerts" => %{"mention" => false, "follow" => true}}
         })
-        |> json_response(200)
+        |> json_response_and_validate_schema(200)
 
       expect = %{
         "alerts" => %{"follow" => true, "mention" => false},
@@ -158,7 +160,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
       assert_error_when_disable_push do
         conn
         |> delete("/api/v1/push/subscription", %{})
-        |> json_response(403)
+        |> json_response_and_validate_schema(403)
       end
     end
 
@@ -166,9 +168,9 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
       res =
         conn
         |> delete("/api/v1/push/subscription", %{})
-        |> json_response(404)
+        |> json_response_and_validate_schema(404)
 
-      assert "Not found" == res
+      assert %{"error" => "Record not found"} == res
     end
 
     test "returns empty result and delete user subsciption", %{
@@ -186,7 +188,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
       res =
         conn
         |> delete("/api/v1/push/subscription", %{})
-        |> json_response(200)
+        |> json_response_and_validate_schema(200)
 
       assert %{} == res
       refute Pleroma.Repo.get(Subscription, subscription.id)

From 8096565653f262844214d715228c31d4ef761f57 Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Tue, 14 Apr 2020 22:50:29 +0400
Subject: [PATCH 53/76] Add OpenAPI spec for MarkerController

---
 .../api_spec/operations/marker_operation.ex   | 52 +++++++++++++++++++
 lib/pleroma/web/api_spec/schemas/marker.ex    | 31 +++++++++++
 .../web/api_spec/schemas/markers_response.ex  | 35 +++++++++++++
 .../schemas/markers_upsert_request.ex         | 35 +++++++++++++
 .../controllers/marker_controller.ex          | 12 ++++-
 .../controllers/marker_controller_test.exs    | 12 ++++-
 6 files changed, 174 insertions(+), 3 deletions(-)
 create mode 100644 lib/pleroma/web/api_spec/operations/marker_operation.ex
 create mode 100644 lib/pleroma/web/api_spec/schemas/marker.ex
 create mode 100644 lib/pleroma/web/api_spec/schemas/markers_response.ex
 create mode 100644 lib/pleroma/web/api_spec/schemas/markers_upsert_request.ex

diff --git a/lib/pleroma/web/api_spec/operations/marker_operation.ex b/lib/pleroma/web/api_spec/operations/marker_operation.ex
new file mode 100644
index 000000000..60adc7c7d
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/marker_operation.ex
@@ -0,0 +1,52 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.MarkerOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Helpers
+  alias Pleroma.Web.ApiSpec.Schemas.MarkersResponse
+  alias Pleroma.Web.ApiSpec.Schemas.MarkersUpsertRequest
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def index_operation do
+    %Operation{
+      tags: ["markers"],
+      summary: "Get saved timeline position",
+      security: [%{"oAuth" => ["read:statuses"]}],
+      operationId: "MarkerController.index",
+      parameters: [
+        Operation.parameter(
+          :timeline,
+          :query,
+          %Schema{
+            type: :array,
+            items: %Schema{type: :string, enum: ["home", "notifications"]}
+          },
+          "Array of markers to fetch. If not provided, an empty object will be returned."
+        )
+      ],
+      responses: %{
+        200 => Operation.response("Marker", "application/json", MarkersResponse)
+      }
+    }
+  end
+
+  def upsert_operation do
+    %Operation{
+      tags: ["markers"],
+      summary: "Save position in timeline",
+      operationId: "MarkerController.upsert",
+      requestBody: Helpers.request_body("Parameters", MarkersUpsertRequest, required: true),
+      security: [%{"oAuth" => ["follow", "write:blocks"]}],
+      responses: %{
+        200 => Operation.response("Marker", "application/json", MarkersResponse)
+      }
+    }
+  end
+end
diff --git a/lib/pleroma/web/api_spec/schemas/marker.ex b/lib/pleroma/web/api_spec/schemas/marker.ex
new file mode 100644
index 000000000..64fca5973
--- /dev/null
+++ b/lib/pleroma/web/api_spec/schemas/marker.ex
@@ -0,0 +1,31 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Schemas.Marker do
+  require OpenApiSpex
+  alias OpenApiSpex.Schema
+
+  OpenApiSpex.schema(%{
+    title: "Marker",
+    description: "Schema for a marker",
+    type: :object,
+    properties: %{
+      last_read_id: %Schema{type: :string},
+      version: %Schema{type: :integer},
+      updated_at: %Schema{type: :string},
+      pleroma: %Schema{
+        type: :object,
+        properties: %{
+          unread_count: %Schema{type: :integer}
+        }
+      }
+    },
+    example: %{
+      "last_read_id" => "35098814",
+      "version" => 361,
+      "updated_at" => "2019-11-26T22:37:25.239Z",
+      "pleroma" => %{"unread_count" => 5}
+    }
+  })
+end
diff --git a/lib/pleroma/web/api_spec/schemas/markers_response.ex b/lib/pleroma/web/api_spec/schemas/markers_response.ex
new file mode 100644
index 000000000..cb1121931
--- /dev/null
+++ b/lib/pleroma/web/api_spec/schemas/markers_response.ex
@@ -0,0 +1,35 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Schemas.MarkersResponse do
+  require OpenApiSpex
+  alias OpenApiSpex.Schema
+
+  alias Pleroma.Web.ApiSpec.Schemas.Marker
+
+  OpenApiSpex.schema(%{
+    title: "MarkersResponse",
+    description: "Response schema for markers",
+    type: :object,
+    properties: %{
+      notifications: %Schema{allOf: [Marker], nullable: true},
+      home: %Schema{allOf: [Marker], nullable: true}
+    },
+    items: %Schema{type: :string},
+    example: %{
+      "notifications" => %{
+        "last_read_id" => "35098814",
+        "version" => 361,
+        "updated_at" => "2019-11-26T22:37:25.239Z",
+        "pleroma" => %{"unread_count" => 0}
+      },
+      "home" => %{
+        "last_read_id" => "103206604258487607",
+        "version" => 468,
+        "updated_at" => "2019-11-26T22:37:25.235Z",
+        "pleroma" => %{"unread_count" => 10}
+      }
+    }
+  })
+end
diff --git a/lib/pleroma/web/api_spec/schemas/markers_upsert_request.ex b/lib/pleroma/web/api_spec/schemas/markers_upsert_request.ex
new file mode 100644
index 000000000..97dcc24b4
--- /dev/null
+++ b/lib/pleroma/web/api_spec/schemas/markers_upsert_request.ex
@@ -0,0 +1,35 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Schemas.MarkersUpsertRequest do
+  require OpenApiSpex
+  alias OpenApiSpex.Schema
+
+  OpenApiSpex.schema(%{
+    title: "MarkersUpsertRequest",
+    description: "Request schema for marker upsert",
+    type: :object,
+    properties: %{
+      notifications: %Schema{
+        type: :object,
+        properties: %{
+          last_read_id: %Schema{type: :string}
+        }
+      },
+      home: %Schema{
+        type: :object,
+        properties: %{
+          last_read_id: %Schema{type: :string}
+        }
+      }
+    },
+    example: %{
+      "home" => %{
+        "last_read_id" => "103194548672408537",
+        "version" => 462,
+        "updated_at" => "2019-11-24T19:39:39.337Z"
+      }
+    }
+  })
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex
index 9f9d4574e..b94171b36 100644
--- a/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex
@@ -15,15 +15,23 @@ defmodule Pleroma.Web.MastodonAPI.MarkerController do
   plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :upsert)
 
   action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
+  plug(OpenApiSpex.Plug.CastAndValidate)
+
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MarkerOperation
 
   # GET /api/v1/markers
   def index(%{assigns: %{user: user}} = conn, params) do
-    markers = Pleroma.Marker.get_markers(user, params["timeline"])
+    markers = Pleroma.Marker.get_markers(user, params[:timeline])
     render(conn, "markers.json", %{markers: markers})
   end
 
   # POST /api/v1/markers
-  def upsert(%{assigns: %{user: user}} = conn, params) do
+  def upsert(%{assigns: %{user: user}, body_params: params} = conn, _) do
+    params =
+      params
+      |> Map.from_struct()
+      |> Map.new(fn {key, value} -> {to_string(key), value} end)
+
     with {:ok, result} <- Pleroma.Marker.upsert(user, params),
          markers <- Map.values(result) do
       render(conn, "markers.json", %{markers: markers})
diff --git a/test/web/mastodon_api/controllers/marker_controller_test.exs b/test/web/mastodon_api/controllers/marker_controller_test.exs
index 919f295bd..1c85ed032 100644
--- a/test/web/mastodon_api/controllers/marker_controller_test.exs
+++ b/test/web/mastodon_api/controllers/marker_controller_test.exs
@@ -4,8 +4,10 @@
 
 defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
   use Pleroma.Web.ConnCase
+  alias Pleroma.Web.ApiSpec
 
   import Pleroma.Factory
+  import OpenApiSpex.TestAssertions
 
   describe "GET /api/v1/markers" do
     test "gets markers with correct scopes", %{conn: conn} do
@@ -22,7 +24,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
         conn
         |> assign(:user, user)
         |> assign(:token, token)
-        |> get("/api/v1/markers", %{timeline: ["notifications"]})
+        |> get("/api/v1/markers?timeline[]=notifications")
         |> json_response(200)
 
       assert response == %{
@@ -32,6 +34,8 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
                  "version" => 0
                }
              }
+
+      assert_schema(response, "MarkersResponse", ApiSpec.spec())
     end
 
     test "gets markers with missed scopes", %{conn: conn} do
@@ -60,6 +64,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
         conn
         |> assign(:user, user)
         |> assign(:token, token)
+        |> put_req_header("content-type", "application/json")
         |> post("/api/v1/markers", %{
           home: %{last_read_id: "777"},
           notifications: %{"last_read_id" => "69420"}
@@ -73,6 +78,8 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
                  "version" => 0
                }
              } = response
+
+      assert_schema(response, "MarkersResponse", ApiSpec.spec())
     end
 
     test "updates exist marker", %{conn: conn} do
@@ -89,6 +96,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
         conn
         |> assign(:user, user)
         |> assign(:token, token)
+        |> put_req_header("content-type", "application/json")
         |> post("/api/v1/markers", %{
           home: %{last_read_id: "777"},
           notifications: %{"last_read_id" => "69888"}
@@ -102,6 +110,8 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
                  "version" => 0
                }
              }
+
+      assert_schema(response, "MarkersResponse", ApiSpec.spec())
     end
 
     test "creates a marker with missed scopes", %{conn: conn} do

From babcae7130d3bc75f85adeef1845997cd091eb84 Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Tue, 5 May 2020 16:45:34 +0400
Subject: [PATCH 54/76] Move single used schemas to Marker operation schema

---
 .../api_spec/operations/marker_operation.ex   | 102 ++++++++++++++++--
 lib/pleroma/web/api_spec/schemas/marker.ex    |  31 ------
 .../web/api_spec/schemas/markers_response.ex  |  35 ------
 .../schemas/markers_upsert_request.ex         |  35 ------
 .../controllers/marker_controller.ex          |   8 +-
 .../web/mastodon_api/views/marker_view.ex     |  13 +--
 .../controllers/marker_controller_test.exs    |  19 ++--
 7 files changed, 111 insertions(+), 132 deletions(-)
 delete mode 100644 lib/pleroma/web/api_spec/schemas/marker.ex
 delete mode 100644 lib/pleroma/web/api_spec/schemas/markers_response.ex
 delete mode 100644 lib/pleroma/web/api_spec/schemas/markers_upsert_request.ex

diff --git a/lib/pleroma/web/api_spec/operations/marker_operation.ex b/lib/pleroma/web/api_spec/operations/marker_operation.ex
index 60adc7c7d..06620492a 100644
--- a/lib/pleroma/web/api_spec/operations/marker_operation.ex
+++ b/lib/pleroma/web/api_spec/operations/marker_operation.ex
@@ -6,8 +6,6 @@ defmodule Pleroma.Web.ApiSpec.MarkerOperation do
   alias OpenApiSpex.Operation
   alias OpenApiSpex.Schema
   alias Pleroma.Web.ApiSpec.Helpers
-  alias Pleroma.Web.ApiSpec.Schemas.MarkersResponse
-  alias Pleroma.Web.ApiSpec.Schemas.MarkersUpsertRequest
 
   def open_api_operation(action) do
     operation = String.to_existing_atom("#{action}_operation")
@@ -16,7 +14,7 @@ defmodule Pleroma.Web.ApiSpec.MarkerOperation do
 
   def index_operation do
     %Operation{
-      tags: ["markers"],
+      tags: ["Markers"],
       summary: "Get saved timeline position",
       security: [%{"oAuth" => ["read:statuses"]}],
       operationId: "MarkerController.index",
@@ -32,21 +30,111 @@ defmodule Pleroma.Web.ApiSpec.MarkerOperation do
         )
       ],
       responses: %{
-        200 => Operation.response("Marker", "application/json", MarkersResponse)
+        200 => Operation.response("Marker", "application/json", response()),
+        403 => Operation.response("Error", "application/json", api_error())
       }
     }
   end
 
   def upsert_operation do
     %Operation{
-      tags: ["markers"],
+      tags: ["Markers"],
       summary: "Save position in timeline",
       operationId: "MarkerController.upsert",
-      requestBody: Helpers.request_body("Parameters", MarkersUpsertRequest, required: true),
+      requestBody: Helpers.request_body("Parameters", upsert_request(), required: true),
       security: [%{"oAuth" => ["follow", "write:blocks"]}],
       responses: %{
-        200 => Operation.response("Marker", "application/json", MarkersResponse)
+        200 => Operation.response("Marker", "application/json", response()),
+        403 => Operation.response("Error", "application/json", api_error())
       }
     }
   end
+
+  defp marker do
+    %Schema{
+      title: "Marker",
+      description: "Schema for a marker",
+      type: :object,
+      properties: %{
+        last_read_id: %Schema{type: :string},
+        version: %Schema{type: :integer},
+        updated_at: %Schema{type: :string},
+        pleroma: %Schema{
+          type: :object,
+          properties: %{
+            unread_count: %Schema{type: :integer}
+          }
+        }
+      },
+      example: %{
+        "last_read_id" => "35098814",
+        "version" => 361,
+        "updated_at" => "2019-11-26T22:37:25.239Z",
+        "pleroma" => %{"unread_count" => 5}
+      }
+    }
+  end
+
+  defp response do
+    %Schema{
+      title: "MarkersResponse",
+      description: "Response schema for markers",
+      type: :object,
+      properties: %{
+        notifications: %Schema{allOf: [marker()], nullable: true},
+        home: %Schema{allOf: [marker()], nullable: true}
+      },
+      items: %Schema{type: :string},
+      example: %{
+        "notifications" => %{
+          "last_read_id" => "35098814",
+          "version" => 361,
+          "updated_at" => "2019-11-26T22:37:25.239Z",
+          "pleroma" => %{"unread_count" => 0}
+        },
+        "home" => %{
+          "last_read_id" => "103206604258487607",
+          "version" => 468,
+          "updated_at" => "2019-11-26T22:37:25.235Z",
+          "pleroma" => %{"unread_count" => 10}
+        }
+      }
+    }
+  end
+
+  defp upsert_request do
+    %Schema{
+      title: "MarkersUpsertRequest",
+      description: "Request schema for marker upsert",
+      type: :object,
+      properties: %{
+        notifications: %Schema{
+          type: :object,
+          properties: %{
+            last_read_id: %Schema{type: :string}
+          }
+        },
+        home: %Schema{
+          type: :object,
+          properties: %{
+            last_read_id: %Schema{type: :string}
+          }
+        }
+      },
+      example: %{
+        "home" => %{
+          "last_read_id" => "103194548672408537",
+          "version" => 462,
+          "updated_at" => "2019-11-24T19:39:39.337Z"
+        }
+      }
+    }
+  end
+
+  defp api_error do
+    %Schema{
+      type: :object,
+      properties: %{error: %Schema{type: :string}}
+    }
+  end
 end
diff --git a/lib/pleroma/web/api_spec/schemas/marker.ex b/lib/pleroma/web/api_spec/schemas/marker.ex
deleted file mode 100644
index 64fca5973..000000000
--- a/lib/pleroma/web/api_spec/schemas/marker.ex
+++ /dev/null
@@ -1,31 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.ApiSpec.Schemas.Marker do
-  require OpenApiSpex
-  alias OpenApiSpex.Schema
-
-  OpenApiSpex.schema(%{
-    title: "Marker",
-    description: "Schema for a marker",
-    type: :object,
-    properties: %{
-      last_read_id: %Schema{type: :string},
-      version: %Schema{type: :integer},
-      updated_at: %Schema{type: :string},
-      pleroma: %Schema{
-        type: :object,
-        properties: %{
-          unread_count: %Schema{type: :integer}
-        }
-      }
-    },
-    example: %{
-      "last_read_id" => "35098814",
-      "version" => 361,
-      "updated_at" => "2019-11-26T22:37:25.239Z",
-      "pleroma" => %{"unread_count" => 5}
-    }
-  })
-end
diff --git a/lib/pleroma/web/api_spec/schemas/markers_response.ex b/lib/pleroma/web/api_spec/schemas/markers_response.ex
deleted file mode 100644
index cb1121931..000000000
--- a/lib/pleroma/web/api_spec/schemas/markers_response.ex
+++ /dev/null
@@ -1,35 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.ApiSpec.Schemas.MarkersResponse do
-  require OpenApiSpex
-  alias OpenApiSpex.Schema
-
-  alias Pleroma.Web.ApiSpec.Schemas.Marker
-
-  OpenApiSpex.schema(%{
-    title: "MarkersResponse",
-    description: "Response schema for markers",
-    type: :object,
-    properties: %{
-      notifications: %Schema{allOf: [Marker], nullable: true},
-      home: %Schema{allOf: [Marker], nullable: true}
-    },
-    items: %Schema{type: :string},
-    example: %{
-      "notifications" => %{
-        "last_read_id" => "35098814",
-        "version" => 361,
-        "updated_at" => "2019-11-26T22:37:25.239Z",
-        "pleroma" => %{"unread_count" => 0}
-      },
-      "home" => %{
-        "last_read_id" => "103206604258487607",
-        "version" => 468,
-        "updated_at" => "2019-11-26T22:37:25.235Z",
-        "pleroma" => %{"unread_count" => 10}
-      }
-    }
-  })
-end
diff --git a/lib/pleroma/web/api_spec/schemas/markers_upsert_request.ex b/lib/pleroma/web/api_spec/schemas/markers_upsert_request.ex
deleted file mode 100644
index 97dcc24b4..000000000
--- a/lib/pleroma/web/api_spec/schemas/markers_upsert_request.ex
+++ /dev/null
@@ -1,35 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.ApiSpec.Schemas.MarkersUpsertRequest do
-  require OpenApiSpex
-  alias OpenApiSpex.Schema
-
-  OpenApiSpex.schema(%{
-    title: "MarkersUpsertRequest",
-    description: "Request schema for marker upsert",
-    type: :object,
-    properties: %{
-      notifications: %Schema{
-        type: :object,
-        properties: %{
-          last_read_id: %Schema{type: :string}
-        }
-      },
-      home: %Schema{
-        type: :object,
-        properties: %{
-          last_read_id: %Schema{type: :string}
-        }
-      }
-    },
-    example: %{
-      "home" => %{
-        "last_read_id" => "103194548672408537",
-        "version" => 462,
-        "updated_at" => "2019-11-24T19:39:39.337Z"
-      }
-    }
-  })
-end
diff --git a/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex
index b94171b36..85310edfa 100644
--- a/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex
@@ -6,6 +6,8 @@ defmodule Pleroma.Web.MastodonAPI.MarkerController do
   use Pleroma.Web, :controller
   alias Pleroma.Plugs.OAuthScopesPlug
 
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
+
   plug(
     OAuthScopesPlug,
     %{scopes: ["read:statuses"]}
@@ -15,7 +17,6 @@ defmodule Pleroma.Web.MastodonAPI.MarkerController do
   plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :upsert)
 
   action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
-  plug(OpenApiSpex.Plug.CastAndValidate)
 
   defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MarkerOperation
 
@@ -27,10 +28,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerController do
 
   # POST /api/v1/markers
   def upsert(%{assigns: %{user: user}, body_params: params} = conn, _) do
-    params =
-      params
-      |> Map.from_struct()
-      |> Map.new(fn {key, value} -> {to_string(key), value} end)
+    params = Map.new(params, fn {key, value} -> {to_string(key), value} end)
 
     with {:ok, result} <- Pleroma.Marker.upsert(user, params),
          markers <- Map.values(result) do
diff --git a/lib/pleroma/web/mastodon_api/views/marker_view.ex b/lib/pleroma/web/mastodon_api/views/marker_view.ex
index 985368fe5..9705b7a91 100644
--- a/lib/pleroma/web/mastodon_api/views/marker_view.ex
+++ b/lib/pleroma/web/mastodon_api/views/marker_view.ex
@@ -6,12 +6,13 @@ defmodule Pleroma.Web.MastodonAPI.MarkerView do
   use Pleroma.Web, :view
 
   def render("markers.json", %{markers: markers}) do
-    Enum.reduce(markers, %{}, fn m, acc ->
-      Map.put_new(acc, m.timeline, %{
-        last_read_id: m.last_read_id,
-        version: m.lock_version,
-        updated_at: NaiveDateTime.to_iso8601(m.updated_at)
-      })
+    Map.new(markers, fn m ->
+      {m.timeline,
+       %{
+         last_read_id: m.last_read_id,
+         version: m.lock_version,
+         updated_at: NaiveDateTime.to_iso8601(m.updated_at)
+       }}
     end)
   end
 end
diff --git a/test/web/mastodon_api/controllers/marker_controller_test.exs b/test/web/mastodon_api/controllers/marker_controller_test.exs
index 1c85ed032..bce719bea 100644
--- a/test/web/mastodon_api/controllers/marker_controller_test.exs
+++ b/test/web/mastodon_api/controllers/marker_controller_test.exs
@@ -4,10 +4,8 @@
 
 defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
   use Pleroma.Web.ConnCase
-  alias Pleroma.Web.ApiSpec
 
   import Pleroma.Factory
-  import OpenApiSpex.TestAssertions
 
   describe "GET /api/v1/markers" do
     test "gets markers with correct scopes", %{conn: conn} do
@@ -25,7 +23,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
         |> assign(:user, user)
         |> assign(:token, token)
         |> get("/api/v1/markers?timeline[]=notifications")
-        |> json_response(200)
+        |> json_response_and_validate_schema(200)
 
       assert response == %{
                "notifications" => %{
@@ -34,8 +32,6 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
                  "version" => 0
                }
              }
-
-      assert_schema(response, "MarkersResponse", ApiSpec.spec())
     end
 
     test "gets markers with missed scopes", %{conn: conn} do
@@ -49,7 +45,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
         |> assign(:user, user)
         |> assign(:token, token)
         |> get("/api/v1/markers", %{timeline: ["notifications"]})
-        |> json_response(403)
+        |> json_response_and_validate_schema(403)
 
       assert response == %{"error" => "Insufficient permissions: read:statuses."}
     end
@@ -69,7 +65,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
           home: %{last_read_id: "777"},
           notifications: %{"last_read_id" => "69420"}
         })
-        |> json_response(200)
+        |> json_response_and_validate_schema(200)
 
       assert %{
                "notifications" => %{
@@ -78,8 +74,6 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
                  "version" => 0
                }
              } = response
-
-      assert_schema(response, "MarkersResponse", ApiSpec.spec())
     end
 
     test "updates exist marker", %{conn: conn} do
@@ -101,7 +95,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
           home: %{last_read_id: "777"},
           notifications: %{"last_read_id" => "69888"}
         })
-        |> json_response(200)
+        |> json_response_and_validate_schema(200)
 
       assert response == %{
                "notifications" => %{
@@ -110,8 +104,6 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
                  "version" => 0
                }
              }
-
-      assert_schema(response, "MarkersResponse", ApiSpec.spec())
     end
 
     test "creates a marker with missed scopes", %{conn: conn} do
@@ -122,11 +114,12 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
         conn
         |> assign(:user, user)
         |> assign(:token, token)
+        |> put_req_header("content-type", "application/json")
         |> post("/api/v1/markers", %{
           home: %{last_read_id: "777"},
           notifications: %{"last_read_id" => "69420"}
         })
-        |> json_response(403)
+        |> json_response_and_validate_schema(403)
 
       assert response == %{"error" => "Insufficient permissions: write:statuses."}
     end

From 5ec6aad5670cf0888942a13e83b9ffd16e97dd18 Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Tue, 5 May 2020 17:05:34 +0400
Subject: [PATCH 55/76] Add OpenAPI spec for ListController

---
 .../api_spec/operations/account_operation.ex  |  19 +-
 .../web/api_spec/operations/list_operation.ex | 189 ++++++++++++++++++
 lib/pleroma/web/api_spec/schemas/list.ex      |  23 +++
 .../controllers/list_controller.ex            |  26 +--
 test/support/conn_case.ex                     |   2 +-
 .../controllers/list_controller_test.exs      |  60 ++++--
 6 files changed, 266 insertions(+), 53 deletions(-)
 create mode 100644 lib/pleroma/web/api_spec/operations/list_operation.ex
 create mode 100644 lib/pleroma/web/api_spec/schemas/list.ex

diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex
index fe9548b1b..470fc0215 100644
--- a/lib/pleroma/web/api_spec/operations/account_operation.ex
+++ b/lib/pleroma/web/api_spec/operations/account_operation.ex
@@ -11,6 +11,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do
   alias Pleroma.Web.ApiSpec.Schemas.ActorType
   alias Pleroma.Web.ApiSpec.Schemas.ApiError
   alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
+  alias Pleroma.Web.ApiSpec.Schemas.List
   alias Pleroma.Web.ApiSpec.Schemas.Status
   alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
 
@@ -646,28 +647,12 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do
     }
   end
 
-  defp list do
-    %Schema{
-      title: "List",
-      description: "Response schema for a list",
-      type: :object,
-      properties: %{
-        id: %Schema{type: :string},
-        title: %Schema{type: :string}
-      },
-      example: %{
-        "id" => "123",
-        "title" => "my list"
-      }
-    }
-  end
-
   defp array_of_lists do
     %Schema{
       title: "ArrayOfLists",
       description: "Response schema for lists",
       type: :array,
-      items: list(),
+      items: List,
       example: [
         %{"id" => "123", "title" => "my list"},
         %{"id" => "1337", "title" => "anotehr list"}
diff --git a/lib/pleroma/web/api_spec/operations/list_operation.ex b/lib/pleroma/web/api_spec/operations/list_operation.ex
new file mode 100644
index 000000000..bb903a379
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/list_operation.ex
@@ -0,0 +1,189 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.ListOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Schemas.Account
+  alias Pleroma.Web.ApiSpec.Schemas.ApiError
+  alias Pleroma.Web.ApiSpec.Schemas.FlakeID
+  alias Pleroma.Web.ApiSpec.Schemas.List
+
+  import Pleroma.Web.ApiSpec.Helpers
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def index_operation do
+    %Operation{
+      tags: ["Lists"],
+      summary: "Show user's lists",
+      description: "Fetch all lists that the user owns",
+      security: [%{"oAuth" => ["read:lists"]}],
+      operationId: "ListController.index",
+      responses: %{
+        200 => Operation.response("Array of List", "application/json", array_of_lists())
+      }
+    }
+  end
+
+  def create_operation do
+    %Operation{
+      tags: ["Lists"],
+      summary: "Show a single list",
+      description: "Fetch the list with the given ID. Used for verifying the title of a list.",
+      operationId: "ListController.create",
+      requestBody: create_update_request(),
+      security: [%{"oAuth" => ["write:lists"]}],
+      responses: %{
+        200 => Operation.response("List", "application/json", List),
+        400 => Operation.response("Error", "application/json", ApiError),
+        404 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  def show_operation do
+    %Operation{
+      tags: ["Lists"],
+      summary: "Show a single list",
+      description: "Fetch the list with the given ID. Used for verifying the title of a list.",
+      operationId: "ListController.show",
+      parameters: [id_param()],
+      security: [%{"oAuth" => ["read:lists"]}],
+      responses: %{
+        200 => Operation.response("List", "application/json", List),
+        404 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  def update_operation do
+    %Operation{
+      tags: ["Lists"],
+      summary: "Update a list",
+      description: "Change the title of a list",
+      operationId: "ListController.update",
+      parameters: [id_param()],
+      requestBody: create_update_request(),
+      security: [%{"oAuth" => ["write:lists"]}],
+      responses: %{
+        200 => Operation.response("List", "application/json", List),
+        422 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  def delete_operation do
+    %Operation{
+      tags: ["Lists"],
+      summary: "Delete a list",
+      operationId: "ListController.delete",
+      parameters: [id_param()],
+      security: [%{"oAuth" => ["write:lists"]}],
+      responses: %{
+        200 => Operation.response("Empty object", "application/json", %Schema{type: :object})
+      }
+    }
+  end
+
+  def list_accounts_operation do
+    %Operation{
+      tags: ["Lists"],
+      summary: "View accounts in list",
+      operationId: "ListController.list_accounts",
+      parameters: [id_param()],
+      security: [%{"oAuth" => ["read:lists"]}],
+      responses: %{
+        200 =>
+          Operation.response("Array of Account", "application/json", %Schema{
+            type: :array,
+            items: Account
+          })
+      }
+    }
+  end
+
+  def add_to_list_operation do
+    %Operation{
+      tags: ["Lists"],
+      summary: "Add accounts to list",
+      description:
+        "Add accounts to the given list. Note that the user must be following these accounts.",
+      operationId: "ListController.add_to_list",
+      parameters: [id_param()],
+      requestBody: add_remove_accounts_request(),
+      security: [%{"oAuth" => ["write:lists"]}],
+      responses: %{
+        200 => Operation.response("Empty object", "application/json", %Schema{type: :object})
+      }
+    }
+  end
+
+  def remove_from_list_operation do
+    %Operation{
+      tags: ["Lists"],
+      summary: "Remove accounts from list",
+      operationId: "ListController.remove_from_list",
+      parameters: [id_param()],
+      requestBody: add_remove_accounts_request(),
+      security: [%{"oAuth" => ["write:lists"]}],
+      responses: %{
+        200 => Operation.response("Empty object", "application/json", %Schema{type: :object})
+      }
+    }
+  end
+
+  defp array_of_lists do
+    %Schema{
+      title: "ArrayOfLists",
+      description: "Response schema for lists",
+      type: :array,
+      items: List,
+      example: [
+        %{"id" => "123", "title" => "my list"},
+        %{"id" => "1337", "title" => "another list"}
+      ]
+    }
+  end
+
+  defp id_param do
+    Operation.parameter(:id, :path, :string, "List ID",
+      example: "123",
+      required: true
+    )
+  end
+
+  defp create_update_request do
+    request_body(
+      "Parameters",
+      %Schema{
+        description: "POST body for creating or updating a List",
+        type: :object,
+        properties: %{
+          title: %Schema{type: :string, description: "List title"}
+        },
+        required: [:title]
+      },
+      required: true
+    )
+  end
+
+  defp add_remove_accounts_request do
+    request_body(
+      "Parameters",
+      %Schema{
+        description: "POST body for adding/removing accounts to/from a List",
+        type: :object,
+        properties: %{
+          account_ids: %Schema{type: :array, description: "Array of account IDs", items: FlakeID}
+        },
+        required: [:account_ids]
+      },
+      required: true
+    )
+  end
+end
diff --git a/lib/pleroma/web/api_spec/schemas/list.ex b/lib/pleroma/web/api_spec/schemas/list.ex
new file mode 100644
index 000000000..78aa0736f
--- /dev/null
+++ b/lib/pleroma/web/api_spec/schemas/list.ex
@@ -0,0 +1,23 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Schemas.List do
+  alias OpenApiSpex.Schema
+
+  require OpenApiSpex
+
+  OpenApiSpex.schema(%{
+    title: "List",
+    description: "Represents a list of some users that the authenticated user follows",
+    type: :object,
+    properties: %{
+      id: %Schema{type: :string, description: "The internal database ID of the list"},
+      title: %Schema{type: :string, description: "The user-defined title of the list"}
+    },
+    example: %{
+      "id" => "12249",
+      "title" => "Friends"
+    }
+  })
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/list_controller.ex b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex
index bfe856025..acdc76fd2 100644
--- a/lib/pleroma/web/mastodon_api/controllers/list_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex
@@ -9,20 +9,17 @@ defmodule Pleroma.Web.MastodonAPI.ListController do
   alias Pleroma.User
   alias Pleroma.Web.MastodonAPI.AccountView
 
-  plug(:list_by_id_and_user when action not in [:index, :create])
-
   @oauth_read_actions [:index, :show, :list_accounts]
 
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
+  plug(:list_by_id_and_user when action not in [:index, :create])
   plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action in @oauth_read_actions)
-
-  plug(
-    OAuthScopesPlug,
-    %{scopes: ["write:lists"]}
-    when action not in @oauth_read_actions
-  )
+  plug(OAuthScopesPlug, %{scopes: ["write:lists"]} when action not in @oauth_read_actions)
 
   action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
 
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ListOperation
+
   # GET /api/v1/lists
   def index(%{assigns: %{user: user}} = conn, opts) do
     lists = Pleroma.List.for_user(user, opts)
@@ -30,7 +27,7 @@ defmodule Pleroma.Web.MastodonAPI.ListController do
   end
 
   # POST /api/v1/lists
-  def create(%{assigns: %{user: user}} = conn, %{"title" => title}) do
+  def create(%{assigns: %{user: user}, body_params: %{title: title}} = conn, _) do
     with {:ok, %Pleroma.List{} = list} <- Pleroma.List.create(title, user) do
       render(conn, "show.json", list: list)
     end
@@ -42,7 +39,7 @@ defmodule Pleroma.Web.MastodonAPI.ListController do
   end
 
   # PUT /api/v1/lists/:id
-  def update(%{assigns: %{list: list}} = conn, %{"title" => title}) do
+  def update(%{assigns: %{list: list}, body_params: %{title: title}} = conn, _) do
     with {:ok, list} <- Pleroma.List.rename(list, title) do
       render(conn, "show.json", list: list)
     end
@@ -65,7 +62,7 @@ defmodule Pleroma.Web.MastodonAPI.ListController do
   end
 
   # POST /api/v1/lists/:id/accounts
-  def add_to_list(%{assigns: %{list: list}} = conn, %{"account_ids" => account_ids}) do
+  def add_to_list(%{assigns: %{list: list}, body_params: %{account_ids: account_ids}} = conn, _) do
     Enum.each(account_ids, fn account_id ->
       with %User{} = followed <- User.get_cached_by_id(account_id) do
         Pleroma.List.follow(list, followed)
@@ -76,7 +73,10 @@ defmodule Pleroma.Web.MastodonAPI.ListController do
   end
 
   # DELETE /api/v1/lists/:id/accounts
-  def remove_from_list(%{assigns: %{list: list}} = conn, %{"account_ids" => account_ids}) do
+  def remove_from_list(
+        %{assigns: %{list: list}, body_params: %{account_ids: account_ids}} = conn,
+        _
+      ) do
     Enum.each(account_ids, fn account_id ->
       with %User{} = followed <- User.get_cached_by_id(account_id) do
         Pleroma.List.unfollow(list, followed)
@@ -86,7 +86,7 @@ defmodule Pleroma.Web.MastodonAPI.ListController do
     json(conn, %{})
   end
 
-  defp list_by_id_and_user(%{assigns: %{user: user}, params: %{"id" => id}} = conn, _) do
+  defp list_by_id_and_user(%{assigns: %{user: user}, params: %{id: id}} = conn, _) do
     case Pleroma.List.get(id, user) do
       %Pleroma.List{} = list -> assign(conn, :list, list)
       nil -> conn |> render_error(:not_found, "List not found") |> halt()
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex
index fa30a0c41..91c03b1a8 100644
--- a/test/support/conn_case.ex
+++ b/test/support/conn_case.ex
@@ -74,7 +74,7 @@ defmodule Pleroma.Web.ConnCase do
         status = Plug.Conn.Status.code(status)
 
         unless lookup[op_id].responses[status] do
-          err = "Response schema not found for #{conn.status} #{conn.method} #{conn.request_path}"
+          err = "Response schema not found for #{status} #{conn.method} #{conn.request_path}"
           flunk(err)
         end
 
diff --git a/test/web/mastodon_api/controllers/list_controller_test.exs b/test/web/mastodon_api/controllers/list_controller_test.exs
index c9c4cbb49..57a9ef4a4 100644
--- a/test/web/mastodon_api/controllers/list_controller_test.exs
+++ b/test/web/mastodon_api/controllers/list_controller_test.exs
@@ -12,37 +12,44 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
   test "creating a list" do
     %{conn: conn} = oauth_access(["write:lists"])
 
-    conn = post(conn, "/api/v1/lists", %{"title" => "cuties"})
-
-    assert %{"title" => title} = json_response(conn, 200)
-    assert title == "cuties"
+    assert %{"title" => "cuties"} =
+             conn
+             |> put_req_header("content-type", "application/json")
+             |> post("/api/v1/lists", %{"title" => "cuties"})
+             |> json_response_and_validate_schema(:ok)
   end
 
   test "renders error for invalid params" do
     %{conn: conn} = oauth_access(["write:lists"])
 
-    conn = post(conn, "/api/v1/lists", %{"title" => nil})
+    conn =
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> post("/api/v1/lists", %{"title" => nil})
 
-    assert %{"error" => "can't be blank"} == json_response(conn, :unprocessable_entity)
+    assert %{"error" => "title - null value where string expected."} =
+             json_response_and_validate_schema(conn, 400)
   end
 
   test "listing a user's lists" do
     %{conn: conn} = oauth_access(["read:lists", "write:lists"])
 
     conn
+    |> put_req_header("content-type", "application/json")
     |> post("/api/v1/lists", %{"title" => "cuties"})
-    |> json_response(:ok)
+    |> json_response_and_validate_schema(:ok)
 
     conn
+    |> put_req_header("content-type", "application/json")
     |> post("/api/v1/lists", %{"title" => "cofe"})
-    |> json_response(:ok)
+    |> json_response_and_validate_schema(:ok)
 
     conn = get(conn, "/api/v1/lists")
 
     assert [
              %{"id" => _, "title" => "cofe"},
              %{"id" => _, "title" => "cuties"}
-           ] = json_response(conn, :ok)
+           ] = json_response_and_validate_schema(conn, :ok)
   end
 
   test "adding users to a list" do
@@ -50,9 +57,12 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
     other_user = insert(:user)
     {:ok, list} = Pleroma.List.create("name", user)
 
-    conn = post(conn, "/api/v1/lists/#{list.id}/accounts", %{"account_ids" => [other_user.id]})
+    assert %{} ==
+             conn
+             |> put_req_header("content-type", "application/json")
+             |> post("/api/v1/lists/#{list.id}/accounts", %{"account_ids" => [other_user.id]})
+             |> json_response_and_validate_schema(:ok)
 
-    assert %{} == json_response(conn, 200)
     %Pleroma.List{following: following} = Pleroma.List.get(list.id, user)
     assert following == [other_user.follower_address]
   end
@@ -65,9 +75,12 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
     {:ok, list} = Pleroma.List.follow(list, other_user)
     {:ok, list} = Pleroma.List.follow(list, third_user)
 
-    conn = delete(conn, "/api/v1/lists/#{list.id}/accounts", %{"account_ids" => [other_user.id]})
+    assert %{} ==
+             conn
+             |> put_req_header("content-type", "application/json")
+             |> delete("/api/v1/lists/#{list.id}/accounts", %{"account_ids" => [other_user.id]})
+             |> json_response_and_validate_schema(:ok)
 
-    assert %{} == json_response(conn, 200)
     %Pleroma.List{following: following} = Pleroma.List.get(list.id, user)
     assert following == [third_user.follower_address]
   end
@@ -83,7 +96,7 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
       |> assign(:user, user)
       |> get("/api/v1/lists/#{list.id}/accounts", %{"account_ids" => [other_user.id]})
 
-    assert [%{"id" => id}] = json_response(conn, 200)
+    assert [%{"id" => id}] = json_response_and_validate_schema(conn, 200)
     assert id == to_string(other_user.id)
   end
 
@@ -96,7 +109,7 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
       |> assign(:user, user)
       |> get("/api/v1/lists/#{list.id}")
 
-    assert %{"id" => id} = json_response(conn, 200)
+    assert %{"id" => id} = json_response_and_validate_schema(conn, 200)
     assert id == to_string(list.id)
   end
 
@@ -105,17 +118,18 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
 
     conn = get(conn, "/api/v1/lists/666")
 
-    assert %{"error" => "List not found"} = json_response(conn, :not_found)
+    assert %{"error" => "List not found"} = json_response_and_validate_schema(conn, :not_found)
   end
 
   test "renaming a list" do
     %{user: user, conn: conn} = oauth_access(["write:lists"])
     {:ok, list} = Pleroma.List.create("name", user)
 
-    conn = put(conn, "/api/v1/lists/#{list.id}", %{"title" => "newname"})
-
-    assert %{"title" => name} = json_response(conn, 200)
-    assert name == "newname"
+    assert %{"title" => "newname"} =
+             conn
+             |> put_req_header("content-type", "application/json")
+             |> put("/api/v1/lists/#{list.id}", %{"title" => "newname"})
+             |> json_response_and_validate_schema(:ok)
   end
 
   test "validates title when renaming a list" do
@@ -125,9 +139,11 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
     conn =
       conn
       |> assign(:user, user)
+      |> put_req_header("content-type", "application/json")
       |> put("/api/v1/lists/#{list.id}", %{"title" => "  "})
 
-    assert %{"error" => "can't be blank"} == json_response(conn, :unprocessable_entity)
+    assert %{"error" => "can't be blank"} ==
+             json_response_and_validate_schema(conn, :unprocessable_entity)
   end
 
   test "deleting a list" do
@@ -136,7 +152,7 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
 
     conn = delete(conn, "/api/v1/lists/#{list.id}")
 
-    assert %{} = json_response(conn, 200)
+    assert %{} = json_response_and_validate_schema(conn, 200)
     assert is_nil(Repo.get(Pleroma.List, list.id))
   end
 end

From f2bf4390f4231d25486b803d426199975996f175 Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Fri, 1 May 2020 19:53:00 +0400
Subject: [PATCH 56/76] Fix descriptions for List API spec

---
 lib/pleroma/web/api_spec/operations/list_operation.ex | 5 ++---
 lib/pleroma/web/api_spec/schemas/list.ex              | 2 +-
 2 files changed, 3 insertions(+), 4 deletions(-)

diff --git a/lib/pleroma/web/api_spec/operations/list_operation.ex b/lib/pleroma/web/api_spec/operations/list_operation.ex
index bb903a379..c88ed5dd0 100644
--- a/lib/pleroma/web/api_spec/operations/list_operation.ex
+++ b/lib/pleroma/web/api_spec/operations/list_operation.ex
@@ -33,7 +33,7 @@ defmodule Pleroma.Web.ApiSpec.ListOperation do
   def create_operation do
     %Operation{
       tags: ["Lists"],
-      summary: "Show a single list",
+      summary: "Create  a list",
       description: "Fetch the list with the given ID. Used for verifying the title of a list.",
       operationId: "ListController.create",
       requestBody: create_update_request(),
@@ -111,8 +111,7 @@ defmodule Pleroma.Web.ApiSpec.ListOperation do
     %Operation{
       tags: ["Lists"],
       summary: "Add accounts to list",
-      description:
-        "Add accounts to the given list. Note that the user must be following these accounts.",
+      description: "Add accounts to the given list.",
       operationId: "ListController.add_to_list",
       parameters: [id_param()],
       requestBody: add_remove_accounts_request(),
diff --git a/lib/pleroma/web/api_spec/schemas/list.ex b/lib/pleroma/web/api_spec/schemas/list.ex
index 78aa0736f..b7d1685c9 100644
--- a/lib/pleroma/web/api_spec/schemas/list.ex
+++ b/lib/pleroma/web/api_spec/schemas/list.ex
@@ -9,7 +9,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.List do
 
   OpenApiSpex.schema(%{
     title: "List",
-    description: "Represents a list of some users that the authenticated user follows",
+    description: "Represents a list of users",
     type: :object,
     properties: %{
       id: %Schema{type: :string, description: "The internal database ID of the list"},

From e7d8ab8303cb69682a75c30a356572a75deb9837 Mon Sep 17 00:00:00 2001
From: Alexander Strizhakov <alex.strizhakov@gmail.com>
Date: Tue, 5 May 2020 16:08:44 +0300
Subject: [PATCH 57/76] admin_api fetch status by id

---
 CHANGELOG.md                                  |  1 +
 docs/API/admin_api.md                         | 11 +++++++++++
 .../web/admin_api/admin_api_controller.ex     | 12 +++++++++++-
 lib/pleroma/web/router.ex                     |  1 +
 .../admin_api/admin_api_controller_test.exs   | 19 +++++++++++++++++++
 5 files changed, 43 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 522285efe..114bfac4d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,6 +30,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Mastodon API: Added `/api/v1/notifications/:id/dismiss` endpoint.
 - Mastodon API: Add support for filtering replies in public and home timelines
 - Admin API: endpoints for create/update/delete OAuth Apps.
+- Admin API: endpoint for status view.
 </details>
 
 ### Fixed
diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md
index 6202c5a1a..23af08961 100644
--- a/docs/API/admin_api.md
+++ b/docs/API/admin_api.md
@@ -755,6 +755,17 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
     - 400 Bad Request `"Invalid parameters"` when `status` is missing
   - On success: `204`, empty response
 
+## `GET /api/pleroma/admin/statuses/:id`
+
+### Show status by id
+
+- Params:
+  - `id`: required, status id
+- Response:
+  - On failure:
+    - 404 Not Found `"Not Found"`
+  - On success: JSON, Mastodon Status entity
+
 ## `PUT /api/pleroma/admin/statuses/:id`
 
 ### Change the scope of an individual reported status
diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex
index 816c11e01..ac661e515 100644
--- a/lib/pleroma/web/admin_api/admin_api_controller.ex
+++ b/lib/pleroma/web/admin_api/admin_api_controller.ex
@@ -93,7 +93,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
   plug(
     OAuthScopesPlug,
     %{scopes: ["read:statuses"], admin: true}
-    when action in [:list_statuses, :list_user_statuses, :list_instance_statuses]
+    when action in [:list_statuses, :list_user_statuses, :list_instance_statuses, :status_show]
   )
 
   plug(
@@ -837,6 +837,16 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
     |> render("index.json", %{activities: activities, as: :activity, skip_relationships: false})
   end
 
+  def status_show(conn, %{"id" => id}) do
+    with %Activity{} = activity <- Activity.get_by_id(id) do
+      conn
+      |> put_view(StatusView)
+      |> render("show.json", %{activity: activity})
+    else
+      _ -> errors(conn, {:error, :not_found})
+    end
+  end
+
   def status_update(%{assigns: %{user: admin}} = conn, %{"id" => id} = params) do
     with {:ok, activity} <- CommonAPI.update_activity_scope(id, params) do
       {:ok, sensitive} = Ecto.Type.cast(:boolean, params["sensitive"])
diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex
index 5b00243e9..ef2239d59 100644
--- a/lib/pleroma/web/router.ex
+++ b/lib/pleroma/web/router.ex
@@ -188,6 +188,7 @@ defmodule Pleroma.Web.Router do
     post("/reports/:id/notes", AdminAPIController, :report_notes_create)
     delete("/reports/:report_id/notes/:id", AdminAPIController, :report_notes_delete)
 
+    get("/statuses/:id", AdminAPIController, :status_show)
     put("/statuses/:id", AdminAPIController, :status_update)
     delete("/statuses/:id", AdminAPIController, :status_delete)
     get("/statuses", AdminAPIController, :list_statuses)
diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs
index 1862a9589..c3f3ad051 100644
--- a/test/web/admin_api/admin_api_controller_test.exs
+++ b/test/web/admin_api/admin_api_controller_test.exs
@@ -1620,6 +1620,25 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
     end
   end
 
+  describe "GET /api/pleroma/admin/statuses/:id" do
+    test "not found", %{conn: conn} do
+      assert conn
+             |> get("/api/pleroma/admin/statuses/not_found")
+             |> json_response(:not_found)
+    end
+
+    test "shows activity", %{conn: conn} do
+      activity = insert(:note_activity)
+
+      response =
+        conn
+        |> get("/api/pleroma/admin/statuses/#{activity.id}")
+        |> json_response(200)
+
+      assert response["id"] == activity.id
+    end
+  end
+
   describe "PUT /api/pleroma/admin/statuses/:id" do
     setup do
       activity = insert(:note_activity)

From 88a14da8172cde6316926b5fbaa2f55b6da6f080 Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Tue, 5 May 2020 17:24:16 +0400
Subject: [PATCH 58/76] Add OpenAPI spec for InstanceController

---
 lib/pleroma/stats.ex                          |   2 +-
 .../api_spec/operations/instance_operation.ex | 169 ++++++++++++++++++
 .../controllers/instance_controller.ex        |   4 +
 .../controllers/instance_controller_test.exs  |   6 +-
 4 files changed, 177 insertions(+), 4 deletions(-)
 create mode 100644 lib/pleroma/web/api_spec/operations/instance_operation.ex

diff --git a/lib/pleroma/stats.ex b/lib/pleroma/stats.ex
index 8d2809bbb..6b3a8a41f 100644
--- a/lib/pleroma/stats.ex
+++ b/lib/pleroma/stats.ex
@@ -91,7 +91,7 @@ defmodule Pleroma.Stats do
       peers: peers,
       stats: %{
         domain_count: domain_count,
-        status_count: status_count,
+        status_count: status_count || 0,
         user_count: user_count
       }
     }
diff --git a/lib/pleroma/web/api_spec/operations/instance_operation.ex b/lib/pleroma/web/api_spec/operations/instance_operation.ex
new file mode 100644
index 000000000..36a1a9043
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex
@@ -0,0 +1,169 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.InstanceOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def show_operation do
+    %Operation{
+      tags: ["Instance"],
+      summary: "Fetch instance",
+      description: "Information about the server",
+      operationId: "InstanceController.show",
+      responses: %{
+        200 => Operation.response("Instance", "application/json", instance())
+      }
+    }
+  end
+
+  def peers_operation do
+    %Operation{
+      tags: ["Instance"],
+      summary: "List of connected domains",
+      operationId: "InstanceController.peers",
+      responses: %{
+        200 => Operation.response("Array of domains", "application/json", array_of_domains())
+      }
+    }
+  end
+
+  defp instance do
+    %Schema{
+      type: :object,
+      properties: %{
+        uri: %Schema{type: :string, description: "The domain name of the instance"},
+        title: %Schema{type: :string, description: "The title of the website"},
+        description: %Schema{
+          type: :string,
+          description: "Admin-defined description of the Mastodon site"
+        },
+        version: %Schema{
+          type: :string,
+          description: "The version of Mastodon installed on the instance"
+        },
+        email: %Schema{
+          type: :string,
+          description: "An email that may be contacted for any inquiries",
+          format: :email
+        },
+        urls: %Schema{
+          type: :object,
+          description: "URLs of interest for clients apps",
+          properties: %{
+            streaming_api: %Schema{
+              type: :string,
+              description: "Websockets address for push streaming"
+            }
+          }
+        },
+        stats: %Schema{
+          type: :object,
+          description: "Statistics about how much information the instance contains",
+          properties: %{
+            user_count: %Schema{
+              type: :integer,
+              description: "Users registered on this instance"
+            },
+            status_count: %Schema{
+              type: :integer,
+              description: "Statuses authored by users on instance"
+            },
+            domain_count: %Schema{
+              type: :integer,
+              description: "Domains federated with this instance"
+            }
+          }
+        },
+        thumbnail: %Schema{
+          type: :string,
+          description: "Banner image for the website",
+          nullable: true
+        },
+        languages: %Schema{
+          type: :array,
+          items: %Schema{type: :string},
+          description: "Primary langauges of the website and its staff"
+        },
+        registrations: %Schema{type: :boolean, description: "Whether registrations are enabled"},
+        # Extra (not present in Mastodon):
+        max_toot_chars: %Schema{
+          type: :integer,
+          description: ": Posts character limit (CW/Subject included in the counter)"
+        },
+        poll_limits: %Schema{
+          type: :object,
+          description: "A map with poll limits for local polls",
+          properties: %{
+            max_options: %Schema{
+              type: :integer,
+              description: "Maximum number of options."
+            },
+            max_option_chars: %Schema{
+              type: :integer,
+              description: "Maximum number of characters per option."
+            },
+            min_expiration: %Schema{
+              type: :integer,
+              description: "Minimum expiration time (in seconds)."
+            },
+            max_expiration: %Schema{
+              type: :integer,
+              description: "Maximum expiration time (in seconds)."
+            }
+          }
+        },
+        upload_limit: %Schema{
+          type: :integer,
+          description: "File size limit of uploads (except for avatar, background, banner)"
+        },
+        avatar_upload_limit: %Schema{type: :integer, description: "The title of the website"},
+        background_upload_limit: %Schema{type: :integer, description: "The title of the website"},
+        banner_upload_limit: %Schema{type: :integer, description: "The title of the website"}
+      },
+      example: %{
+        "avatar_upload_limit" => 2_000_000,
+        "background_upload_limit" => 4_000_000,
+        "banner_upload_limit" => 4_000_000,
+        "description" => "A Pleroma instance, an alternative fediverse server",
+        "email" => "lain@lain.com",
+        "languages" => ["en"],
+        "max_toot_chars" => 5000,
+        "poll_limits" => %{
+          "max_expiration" => 31_536_000,
+          "max_option_chars" => 200,
+          "max_options" => 20,
+          "min_expiration" => 0
+        },
+        "registrations" => false,
+        "stats" => %{
+          "domain_count" => 2996,
+          "status_count" => 15_802,
+          "user_count" => 5
+        },
+        "thumbnail" => "https://lain.com/instance/thumbnail.jpeg",
+        "title" => "lain.com",
+        "upload_limit" => 16_000_000,
+        "uri" => "https://lain.com",
+        "urls" => %{
+          "streaming_api" => "wss://lain.com"
+        },
+        "version" => "2.7.2 (compatible; Pleroma 2.0.50-536-g25eec6d7-develop)"
+      }
+    }
+  end
+
+  defp array_of_domains do
+    %Schema{
+      type: :array,
+      items: %Schema{type: :string},
+      example: ["pleroma.site", "lain.com", "bikeshed.party"]
+    }
+  end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex
index 237f85677..d8859731d 100644
--- a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex
@@ -5,12 +5,16 @@
 defmodule Pleroma.Web.MastodonAPI.InstanceController do
   use Pleroma.Web, :controller
 
+  plug(OpenApiSpex.Plug.CastAndValidate)
+
   plug(
     :skip_plug,
     [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug]
     when action in [:show, :peers]
   )
 
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.InstanceOperation
+
   @doc "GET /api/v1/instance"
   def show(conn, _params) do
     render(conn, "show.json")
diff --git a/test/web/mastodon_api/controllers/instance_controller_test.exs b/test/web/mastodon_api/controllers/instance_controller_test.exs
index 2c7fd9fd0..90840d5ab 100644
--- a/test/web/mastodon_api/controllers/instance_controller_test.exs
+++ b/test/web/mastodon_api/controllers/instance_controller_test.exs
@@ -10,7 +10,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do
 
   test "get instance information", %{conn: conn} do
     conn = get(conn, "/api/v1/instance")
-    assert result = json_response(conn, 200)
+    assert result = json_response_and_validate_schema(conn, 200)
 
     email = Pleroma.Config.get([:instance, :email])
     # Note: not checking for "max_toot_chars" since it's optional
@@ -56,7 +56,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do
 
     conn = get(conn, "/api/v1/instance")
 
-    assert result = json_response(conn, 200)
+    assert result = json_response_and_validate_schema(conn, 200)
 
     stats = result["stats"]
 
@@ -74,7 +74,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do
 
     conn = get(conn, "/api/v1/instance/peers")
 
-    assert result = json_response(conn, 200)
+    assert result = json_response_and_validate_schema(conn, 200)
 
     assert ["peer1.com", "peer2.com"] == Enum.sort(result)
   end

From b5189d2c50929aa67293e2e39ca020bad43f5f8b Mon Sep 17 00:00:00 2001
From: minibikini <egor@kislitsyn.com>
Date: Thu, 30 Apr 2020 17:45:48 +0000
Subject: [PATCH 59/76] Apply suggestion to
 lib/pleroma/web/api_spec/operations/instance_operation.ex

---
 lib/pleroma/web/api_spec/operations/instance_operation.ex | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/lib/pleroma/web/api_spec/operations/instance_operation.ex b/lib/pleroma/web/api_spec/operations/instance_operation.ex
index 36a1a9043..9407fa74d 100644
--- a/lib/pleroma/web/api_spec/operations/instance_operation.ex
+++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex
@@ -26,7 +26,7 @@ defmodule Pleroma.Web.ApiSpec.InstanceOperation do
   def peers_operation do
     %Operation{
       tags: ["Instance"],
-      summary: "List of connected domains",
+      summary: "List of known hosts",
       operationId: "InstanceController.peers",
       responses: %{
         200 => Operation.response("Array of domains", "application/json", array_of_domains())

From 3817f179d777058259324d2e300780da06cce460 Mon Sep 17 00:00:00 2001
From: minibikini <egor@kislitsyn.com>
Date: Fri, 1 May 2020 12:46:53 +0000
Subject: [PATCH 60/76] Apply suggestion to
 lib/pleroma/web/api_spec/operations/instance_operation.ex

---
 lib/pleroma/web/api_spec/operations/instance_operation.ex | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/lib/pleroma/web/api_spec/operations/instance_operation.ex b/lib/pleroma/web/api_spec/operations/instance_operation.ex
index 9407fa74d..5644cb54d 100644
--- a/lib/pleroma/web/api_spec/operations/instance_operation.ex
+++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex
@@ -46,7 +46,7 @@ defmodule Pleroma.Web.ApiSpec.InstanceOperation do
         },
         version: %Schema{
           type: :string,
-          description: "The version of Mastodon installed on the instance"
+          description: "The version of Pleroma installed on the instance"
         },
         email: %Schema{
           type: :string,

From 42a4a863f159b863ec4617fc47697e11f92ff956 Mon Sep 17 00:00:00 2001
From: minibikini <egor@kislitsyn.com>
Date: Fri, 1 May 2020 12:46:56 +0000
Subject: [PATCH 61/76] Apply suggestion to
 lib/pleroma/web/api_spec/operations/instance_operation.ex

---
 lib/pleroma/web/api_spec/operations/instance_operation.ex | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/lib/pleroma/web/api_spec/operations/instance_operation.ex b/lib/pleroma/web/api_spec/operations/instance_operation.ex
index 5644cb54d..880bd3f1b 100644
--- a/lib/pleroma/web/api_spec/operations/instance_operation.ex
+++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex
@@ -42,7 +42,7 @@ defmodule Pleroma.Web.ApiSpec.InstanceOperation do
         title: %Schema{type: :string, description: "The title of the website"},
         description: %Schema{
           type: :string,
-          description: "Admin-defined description of the Mastodon site"
+          description: "Admin-defined description of the Pleroma site"
         },
         version: %Schema{
           type: :string,

From ec1e4b4f1acb81fc36b396e7f58f67928dc6a0df Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Tue, 5 May 2020 17:40:00 +0400
Subject: [PATCH 62/76] Add OpenAPI spec for FollowRequestController

---
 .../operations/follow_request_operation.ex    | 65 +++++++++++++++++++
 .../controllers/follow_request_controller.ex  |  5 +-
 .../follow_request_controller_test.exs        |  6 +-
 3 files changed, 72 insertions(+), 4 deletions(-)
 create mode 100644 lib/pleroma/web/api_spec/operations/follow_request_operation.ex

diff --git a/lib/pleroma/web/api_spec/operations/follow_request_operation.ex b/lib/pleroma/web/api_spec/operations/follow_request_operation.ex
new file mode 100644
index 000000000..ac4aee6da
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/follow_request_operation.ex
@@ -0,0 +1,65 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.FollowRequestOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Schemas.Account
+  alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def index_operation do
+    %Operation{
+      tags: ["Follow Requests"],
+      summary: "Pending Follows",
+      security: [%{"oAuth" => ["read:follows", "follow"]}],
+      operationId: "FollowRequestController.index",
+      responses: %{
+        200 =>
+          Operation.response("Array of Account", "application/json", %Schema{
+            type: :array,
+            items: Account,
+            example: [Account.schema().example]
+          })
+      }
+    }
+  end
+
+  def authorize_operation do
+    %Operation{
+      tags: ["Follow Requests"],
+      summary: "Accept Follow",
+      operationId: "FollowRequestController.authorize",
+      parameters: [id_param()],
+      security: [%{"oAuth" => ["follow", "write:follows"]}],
+      responses: %{
+        200 => Operation.response("Relationship", "application/json", AccountRelationship)
+      }
+    }
+  end
+
+  def reject_operation do
+    %Operation{
+      tags: ["Follow Requests"],
+      summary: "Reject Follow",
+      operationId: "FollowRequestController.reject",
+      parameters: [id_param()],
+      security: [%{"oAuth" => ["follow", "write:follows"]}],
+      responses: %{
+        200 => Operation.response("Relationship", "application/json", AccountRelationship)
+      }
+    }
+  end
+
+  defp id_param do
+    Operation.parameter(:id, :path, :string, "Conversation ID",
+      example: "123",
+      required: true
+    )
+  end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex
index 25f2269b9..748b6b475 100644
--- a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex
@@ -10,6 +10,7 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestController do
   alias Pleroma.Web.CommonAPI
 
   plug(:put_view, Pleroma.Web.MastodonAPI.AccountView)
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
   plug(:assign_follower when action != :index)
 
   action_fallback(:errors)
@@ -21,6 +22,8 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestController do
     %{scopes: ["follow", "write:follows"]} when action != :index
   )
 
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.FollowRequestOperation
+
   @doc "GET /api/v1/follow_requests"
   def index(%{assigns: %{user: followed}} = conn, _params) do
     follow_requests = User.get_follow_requests(followed)
@@ -42,7 +45,7 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestController do
     end
   end
 
-  defp assign_follower(%{params: %{"id" => id}} = conn, _) do
+  defp assign_follower(%{params: %{id: id}} = conn, _) do
     case User.get_cached_by_id(id) do
       %User{} = follower -> assign(conn, :follower, follower)
       nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt()
diff --git a/test/web/mastodon_api/controllers/follow_request_controller_test.exs b/test/web/mastodon_api/controllers/follow_request_controller_test.exs
index d8dbe4800..44e12d15a 100644
--- a/test/web/mastodon_api/controllers/follow_request_controller_test.exs
+++ b/test/web/mastodon_api/controllers/follow_request_controller_test.exs
@@ -27,7 +27,7 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestControllerTest do
 
       conn = get(conn, "/api/v1/follow_requests")
 
-      assert [relationship] = json_response(conn, 200)
+      assert [relationship] = json_response_and_validate_schema(conn, 200)
       assert to_string(other_user.id) == relationship["id"]
     end
 
@@ -44,7 +44,7 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestControllerTest do
 
       conn = post(conn, "/api/v1/follow_requests/#{other_user.id}/authorize")
 
-      assert relationship = json_response(conn, 200)
+      assert relationship = json_response_and_validate_schema(conn, 200)
       assert to_string(other_user.id) == relationship["id"]
 
       user = User.get_cached_by_id(user.id)
@@ -62,7 +62,7 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestControllerTest do
 
       conn = post(conn, "/api/v1/follow_requests/#{other_user.id}/reject")
 
-      assert relationship = json_response(conn, 200)
+      assert relationship = json_response_and_validate_schema(conn, 200)
       assert to_string(other_user.id) == relationship["id"]
 
       user = User.get_cached_by_id(user.id)

From 7e7a3e15449792581412be002f287c504e3449a6 Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Tue, 14 Apr 2020 18:36:32 +0400
Subject: [PATCH 63/76] Add OpenAPI spec for FilterController

---
 lib/pleroma/filter.ex                         |  9 +-
 .../api_spec/operations/filter_operation.ex   | 89 +++++++++++++++++++
 lib/pleroma/web/api_spec/schemas/filter.ex    | 51 +++++++++++
 .../api_spec/schemas/filter_create_request.ex | 30 +++++++
 .../api_spec/schemas/filter_update_request.ex | 41 +++++++++
 .../web/api_spec/schemas/filters_response.ex  | 40 +++++++++
 .../controllers/filter_controller.ex          | 54 +++++------
 .../web/mastodon_api/views/filter_view.ex     |  6 +-
 test/filter_test.exs                          | 10 +--
 .../controllers/filter_controller_test.exs    | 55 ++++++++++--
 10 files changed, 340 insertions(+), 45 deletions(-)
 create mode 100644 lib/pleroma/web/api_spec/operations/filter_operation.ex
 create mode 100644 lib/pleroma/web/api_spec/schemas/filter.ex
 create mode 100644 lib/pleroma/web/api_spec/schemas/filter_create_request.ex
 create mode 100644 lib/pleroma/web/api_spec/schemas/filter_update_request.ex
 create mode 100644 lib/pleroma/web/api_spec/schemas/filters_response.ex

diff --git a/lib/pleroma/filter.ex b/lib/pleroma/filter.ex
index 7cb49360f..4d61b3650 100644
--- a/lib/pleroma/filter.ex
+++ b/lib/pleroma/filter.ex
@@ -89,11 +89,10 @@ defmodule Pleroma.Filter do
     |> Repo.delete()
   end
 
-  def update(%Pleroma.Filter{} = filter) do
-    destination = Map.from_struct(filter)
-
-    Pleroma.Filter.get(filter.filter_id, %{id: filter.user_id})
-    |> cast(destination, [:phrase, :context, :hide, :expires_at, :whole_word])
+  def update(%Pleroma.Filter{} = filter, params) do
+    filter
+    |> cast(params, [:phrase, :context, :hide, :expires_at, :whole_word])
+    |> validate_required([:phrase, :context])
     |> Repo.update()
   end
 end
diff --git a/lib/pleroma/web/api_spec/operations/filter_operation.ex b/lib/pleroma/web/api_spec/operations/filter_operation.ex
new file mode 100644
index 000000000..0d673f566
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/filter_operation.ex
@@ -0,0 +1,89 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.FilterOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Helpers
+  alias Pleroma.Web.ApiSpec.Schemas.Filter
+  alias Pleroma.Web.ApiSpec.Schemas.FilterCreateRequest
+  alias Pleroma.Web.ApiSpec.Schemas.FiltersResponse
+  alias Pleroma.Web.ApiSpec.Schemas.FilterUpdateRequest
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def index_operation do
+    %Operation{
+      tags: ["apps"],
+      summary: "View all filters",
+      operationId: "FilterController.index",
+      security: [%{"oAuth" => ["read:filters"]}],
+      responses: %{
+        200 => Operation.response("Filters", "application/json", FiltersResponse)
+      }
+    }
+  end
+
+  def create_operation do
+    %Operation{
+      tags: ["apps"],
+      summary: "Create a filter",
+      operationId: "FilterController.create",
+      requestBody: Helpers.request_body("Parameters", FilterCreateRequest, required: true),
+      security: [%{"oAuth" => ["write:filters"]}],
+      responses: %{200 => Operation.response("Filter", "application/json", Filter)}
+    }
+  end
+
+  def show_operation do
+    %Operation{
+      tags: ["apps"],
+      summary: "View all filters",
+      parameters: [id_param()],
+      operationId: "FilterController.show",
+      security: [%{"oAuth" => ["read:filters"]}],
+      responses: %{
+        200 => Operation.response("Filter", "application/json", Filter)
+      }
+    }
+  end
+
+  def update_operation do
+    %Operation{
+      tags: ["apps"],
+      summary: "Update a filter",
+      parameters: [id_param()],
+      operationId: "FilterController.update",
+      requestBody: Helpers.request_body("Parameters", FilterUpdateRequest, required: true),
+      security: [%{"oAuth" => ["write:filters"]}],
+      responses: %{
+        200 => Operation.response("Filter", "application/json", Filter)
+      }
+    }
+  end
+
+  def delete_operation do
+    %Operation{
+      tags: ["apps"],
+      summary: "Remove a filter",
+      parameters: [id_param()],
+      operationId: "FilterController.delete",
+      security: [%{"oAuth" => ["write:filters"]}],
+      responses: %{
+        200 =>
+          Operation.response("Filter", "application/json", %Schema{
+            type: :object,
+            description: "Empty object"
+          })
+      }
+    }
+  end
+
+  defp id_param do
+    Operation.parameter(:id, :path, :string, "Filter ID", example: "123", required: true)
+  end
+end
diff --git a/lib/pleroma/web/api_spec/schemas/filter.ex b/lib/pleroma/web/api_spec/schemas/filter.ex
new file mode 100644
index 000000000..fc5480b71
--- /dev/null
+++ b/lib/pleroma/web/api_spec/schemas/filter.ex
@@ -0,0 +1,51 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Schemas.Filter do
+  alias OpenApiSpex.Schema
+  require OpenApiSpex
+
+  OpenApiSpex.schema(%{
+    title: "Filter",
+    type: :object,
+    properties: %{
+      id: %Schema{type: :string},
+      phrase: %Schema{type: :string, description: "The text to be filtered"},
+      context: %Schema{
+        type: :array,
+        items: %Schema{type: :string, enum: ["home", "notifications", "public", "thread"]},
+        description: "The contexts in which the filter should be applied."
+      },
+      expires_at: %Schema{
+        type: :string,
+        format: :"date-time",
+        description:
+          "When the filter should no longer be applied. String (ISO 8601 Datetime), or null if the filter does not expire.",
+        nullable: true
+      },
+      irreversible: %Schema{
+        type: :boolean,
+        description:
+          "Should matching entities in home and notifications be dropped by the server?"
+      },
+      whole_word: %Schema{
+        type: :boolean,
+        description: "Should the filter consider word boundaries?"
+      }
+    },
+    example: %{
+      "id" => "5580",
+      "phrase" => "@twitter.com",
+      "context" => [
+        "home",
+        "notifications",
+        "public",
+        "thread"
+      ],
+      "whole_word" => false,
+      "expires_at" => nil,
+      "irreversible" => true
+    }
+  })
+end
diff --git a/lib/pleroma/web/api_spec/schemas/filter_create_request.ex b/lib/pleroma/web/api_spec/schemas/filter_create_request.ex
new file mode 100644
index 000000000..f2a475b12
--- /dev/null
+++ b/lib/pleroma/web/api_spec/schemas/filter_create_request.ex
@@ -0,0 +1,30 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Schemas.FilterCreateRequest do
+  alias OpenApiSpex.Schema
+  require OpenApiSpex
+
+  OpenApiSpex.schema(%{
+    title: "FilterCreateRequest",
+    allOf: [
+      %OpenApiSpex.Reference{"$ref": "#/components/schemas/FilterUpdateRequest"},
+      %Schema{
+        type: :object,
+        properties: %{
+          irreversible: %Schema{
+            type: :bolean,
+            description:
+              "Should the server irreversibly drop matching entities from home and notifications?",
+            default: false
+          }
+        }
+      }
+    ],
+    example: %{
+      "phrase" => "knights",
+      "context" => ["home"]
+    }
+  })
+end
diff --git a/lib/pleroma/web/api_spec/schemas/filter_update_request.ex b/lib/pleroma/web/api_spec/schemas/filter_update_request.ex
new file mode 100644
index 000000000..e703db0ce
--- /dev/null
+++ b/lib/pleroma/web/api_spec/schemas/filter_update_request.ex
@@ -0,0 +1,41 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Schemas.FilterUpdateRequest do
+  alias OpenApiSpex.Schema
+  require OpenApiSpex
+
+  OpenApiSpex.schema(%{
+    title: "FilterUpdateRequest",
+    type: :object,
+    properties: %{
+      phrase: %Schema{type: :string, description: "The text to be filtered"},
+      context: %Schema{
+        type: :array,
+        items: %Schema{type: :string, enum: ["home", "notifications", "public", "thread"]},
+        description:
+          "Array of enumerable strings `home`, `notifications`, `public`, `thread`. At least one context must be specified."
+      },
+      irreversible: %Schema{
+        type: :bolean,
+        description:
+          "Should the server irreversibly drop matching entities from home and notifications?"
+      },
+      whole_word: %Schema{type: :bolean, description: "Consider word boundaries?", default: true}
+      # TODO: probably should implement filter expiration
+      # expires_in: %Schema{
+      #   type: :string,
+      #   format: :"date-time",
+      #   description:
+      #     "ISO 8601 Datetime for when the filter expires. Otherwise,
+      #  null for a filter that doesn't expire."
+      # }
+    },
+    required: [:phrase, :context],
+    example: %{
+      "phrase" => "knights",
+      "context" => ["home"]
+    }
+  })
+end
diff --git a/lib/pleroma/web/api_spec/schemas/filters_response.ex b/lib/pleroma/web/api_spec/schemas/filters_response.ex
new file mode 100644
index 000000000..8c56c5982
--- /dev/null
+++ b/lib/pleroma/web/api_spec/schemas/filters_response.ex
@@ -0,0 +1,40 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Schemas.FiltersResponse do
+  require OpenApiSpex
+  alias Pleroma.Web.ApiSpec.Schemas.Filter
+
+  OpenApiSpex.schema(%{
+    title: "FiltersResponse",
+    description: "Array of Filters",
+    type: :array,
+    items: Filter,
+    example: [
+      %{
+        "id" => "5580",
+        "phrase" => "@twitter.com",
+        "context" => [
+          "home",
+          "notifications",
+          "public",
+          "thread"
+        ],
+        "whole_word" => false,
+        "expires_at" => nil,
+        "irreversible" => true
+      },
+      %{
+        "id" => "6191",
+        "phrase" => ":eurovision2019:",
+        "context" => [
+          "home"
+        ],
+        "whole_word" => true,
+        "expires_at" => "2019-05-21T13:47:31.333Z",
+        "irreversible" => false
+      }
+    ]
+  })
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex
index 7fd0562c9..dd13a8a09 100644
--- a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex
@@ -10,67 +10,69 @@ defmodule Pleroma.Web.MastodonAPI.FilterController do
 
   @oauth_read_actions [:show, :index]
 
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
   plug(OAuthScopesPlug, %{scopes: ["read:filters"]} when action in @oauth_read_actions)
 
   plug(
     OAuthScopesPlug,
     %{scopes: ["write:filters"]} when action not in @oauth_read_actions
   )
+  
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.FilterOperation
 
   @doc "GET /api/v1/filters"
   def index(%{assigns: %{user: user}} = conn, _) do
     filters = Filter.get_filters(user)
 
-    render(conn, "filters.json", filters: filters)
+    render(conn, "index.json", filters: filters)
   end
 
   @doc "POST /api/v1/filters"
-  def create(
-        %{assigns: %{user: user}} = conn,
-        %{"phrase" => phrase, "context" => context} = params
-      ) do
+  def create(%{assigns: %{user: user}, body_params: params} = conn, _) do
     query = %Filter{
       user_id: user.id,
-      phrase: phrase,
-      context: context,
-      hide: Map.get(params, "irreversible", false),
-      whole_word: Map.get(params, "boolean", true)
+      phrase: params.phrase,
+      context: params.context,
+      hide: params.irreversible,
+      whole_word: params.whole_word
       # expires_at
     }
 
     {:ok, response} = Filter.create(query)
 
-    render(conn, "filter.json", filter: response)
+    render(conn, "show.json", filter: response)
   end
 
   @doc "GET /api/v1/filters/:id"
-  def show(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do
+  def show(%{assigns: %{user: user}} = conn, %{id: filter_id}) do
     filter = Filter.get(filter_id, user)
 
-    render(conn, "filter.json", filter: filter)
+    render(conn, "show.json", filter: filter)
   end
 
   @doc "PUT /api/v1/filters/:id"
   def update(
-        %{assigns: %{user: user}} = conn,
-        %{"phrase" => phrase, "context" => context, "id" => filter_id} = params
+        %{assigns: %{user: user}, body_params: params} = conn,
+        %{id: filter_id}
       ) do
-    query = %Filter{
-      user_id: user.id,
-      filter_id: filter_id,
-      phrase: phrase,
-      context: context,
-      hide: Map.get(params, "irreversible", nil),
-      whole_word: Map.get(params, "boolean", true)
-      # expires_at
-    }
+    params =
+      params
+      |> Map.from_struct()
+      |> Map.delete(:irreversible)
+      |> Map.put(:hide, params.irreversible)
+      |> Enum.reject(fn {_key, value} -> is_nil(value) end)
+      |> Map.new()
 
-    {:ok, response} = Filter.update(query)
-    render(conn, "filter.json", filter: response)
+    # TODO: add expires_in -> expires_at
+
+    with %Filter{} = filter <- Filter.get(filter_id, user),
+         {:ok, %Filter{} = filter} <- Filter.update(filter, params) do
+      render(conn, "show.json", filter: filter)
+    end
   end
 
   @doc "DELETE /api/v1/filters/:id"
-  def delete(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do
+  def delete(%{assigns: %{user: user}} = conn, %{id: filter_id}) do
     query = %Filter{
       user_id: user.id,
       filter_id: filter_id
diff --git a/lib/pleroma/web/mastodon_api/views/filter_view.ex b/lib/pleroma/web/mastodon_api/views/filter_view.ex
index 97fd1e83f..8d5c381ec 100644
--- a/lib/pleroma/web/mastodon_api/views/filter_view.ex
+++ b/lib/pleroma/web/mastodon_api/views/filter_view.ex
@@ -7,11 +7,11 @@ defmodule Pleroma.Web.MastodonAPI.FilterView do
   alias Pleroma.Web.CommonAPI.Utils
   alias Pleroma.Web.MastodonAPI.FilterView
 
-  def render("filters.json", %{filters: filters} = opts) do
-    render_many(filters, FilterView, "filter.json", opts)
+  def render("index.json", %{filters: filters} = opts) do
+    render_many(filters, FilterView, "show.json", opts)
   end
 
-  def render("filter.json", %{filter: filter}) do
+  def render("show.json", %{filter: filter}) do
     expires_at =
       if filter.expires_at do
         Utils.to_masto_date(filter.expires_at)
diff --git a/test/filter_test.exs b/test/filter_test.exs
index b2a8330ee..63a30c736 100644
--- a/test/filter_test.exs
+++ b/test/filter_test.exs
@@ -141,17 +141,15 @@ defmodule Pleroma.FilterTest do
       context: ["home"]
     }
 
-    query_two = %Pleroma.Filter{
-      user_id: user.id,
-      filter_id: 1,
+    changes = %{
       phrase: "who",
       context: ["home", "timeline"]
     }
 
     {:ok, filter_one} = Pleroma.Filter.create(query_one)
-    {:ok, filter_two} = Pleroma.Filter.update(query_two)
+    {:ok, filter_two} = Pleroma.Filter.update(filter_one, changes)
     assert filter_one != filter_two
-    assert filter_two.phrase == query_two.phrase
-    assert filter_two.context == query_two.context
+    assert filter_two.phrase == changes.phrase
+    assert filter_two.context == changes.context
   end
 end
diff --git a/test/web/mastodon_api/controllers/filter_controller_test.exs b/test/web/mastodon_api/controllers/filter_controller_test.exs
index 97ab005e0..41a290eb2 100644
--- a/test/web/mastodon_api/controllers/filter_controller_test.exs
+++ b/test/web/mastodon_api/controllers/filter_controller_test.exs
@@ -5,8 +5,15 @@
 defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
   use Pleroma.Web.ConnCase
 
+  alias Pleroma.Web.ApiSpec
+  alias Pleroma.Web.ApiSpec.Schemas.Filter
+  alias Pleroma.Web.ApiSpec.Schemas.FilterCreateRequest
+  alias Pleroma.Web.ApiSpec.Schemas.FiltersResponse
+  alias Pleroma.Web.ApiSpec.Schemas.FilterUpdateRequest
   alias Pleroma.Web.MastodonAPI.FilterView
 
+  import OpenApiSpex.TestAssertions
+
   test "creating a filter" do
     %{conn: conn} = oauth_access(["write:filters"])
 
@@ -15,7 +22,10 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
       context: ["home"]
     }
 
-    conn = post(conn, "/api/v1/filters", %{"phrase" => filter.phrase, context: filter.context})
+    conn =
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> post("/api/v1/filters", %{"phrase" => filter.phrase, context: filter.context})
 
     assert response = json_response(conn, 200)
     assert response["phrase"] == filter.phrase
@@ -23,6 +33,7 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
     assert response["irreversible"] == false
     assert response["id"] != nil
     assert response["id"] != ""
+    assert_schema(response, "Filter", ApiSpec.spec())
   end
 
   test "fetching a list of filters" do
@@ -53,9 +64,11 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
     assert response ==
              render_json(
                FilterView,
-               "filters.json",
+               "index.json",
                filters: [filter_two, filter_one]
              )
+
+    assert_schema(response, "FiltersResponse", ApiSpec.spec())
   end
 
   test "get a filter" do
@@ -72,7 +85,8 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
 
     conn = get(conn, "/api/v1/filters/#{filter.filter_id}")
 
-    assert _response = json_response(conn, 200)
+    assert response = json_response(conn, 200)
+    assert_schema(response, "Filter", ApiSpec.spec())
   end
 
   test "update a filter" do
@@ -82,7 +96,8 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
       user_id: user.id,
       filter_id: 2,
       phrase: "knight",
-      context: ["home"]
+      context: ["home"],
+      hide: true
     }
 
     {:ok, _filter} = Pleroma.Filter.create(query)
@@ -93,7 +108,9 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
     }
 
     conn =
-      put(conn, "/api/v1/filters/#{query.filter_id}", %{
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> put("/api/v1/filters/#{query.filter_id}", %{
         phrase: new.phrase,
         context: new.context
       })
@@ -101,6 +118,8 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
     assert response = json_response(conn, 200)
     assert response["phrase"] == new.phrase
     assert response["context"] == new.context
+    assert response["irreversible"] == true
+    assert_schema(response, "Filter", ApiSpec.spec())
   end
 
   test "delete a filter" do
@@ -120,4 +139,30 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
     assert response = json_response(conn, 200)
     assert response == %{}
   end
+
+  describe "OpenAPI" do
+    test "Filter example matches schema" do
+      api_spec = ApiSpec.spec()
+      schema = Filter.schema()
+      assert_schema(schema.example, "Filter", api_spec)
+    end
+
+    test "FiltersResponse example matches schema" do
+      api_spec = ApiSpec.spec()
+      schema = FiltersResponse.schema()
+      assert_schema(schema.example, "FiltersResponse", api_spec)
+    end
+
+    test "FilterCreateRequest example matches schema" do
+      api_spec = ApiSpec.spec()
+      schema = FilterCreateRequest.schema()
+      assert_schema(schema.example, "FilterCreateRequest", api_spec)
+    end
+
+    test "FilterUpdateRequest example matches schema" do
+      api_spec = ApiSpec.spec()
+      schema = FilterUpdateRequest.schema()
+      assert_schema(schema.example, "FilterUpdateRequest", api_spec)
+    end
+  end
 end

From 46aae346f8530d4b9933b8e718e9578a96447f0a Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Mon, 27 Apr 2020 23:54:11 +0400
Subject: [PATCH 64/76] Move single used schemas to Filter operation schema

---
 .../api_spec/operations/filter_operation.ex   | 158 ++++++++++++++++--
 lib/pleroma/web/api_spec/schemas/filter.ex    |  51 ------
 .../api_spec/schemas/filter_create_request.ex |  30 ----
 .../api_spec/schemas/filter_update_request.ex |  41 -----
 .../web/api_spec/schemas/filters_response.ex  |  40 -----
 .../controllers/filter_controller.ex          |   7 +-
 .../web/mastodon_api/views/filter_view.ex     |   4 +-
 .../controllers/filter_controller_test.exs    |  49 +-----
 8 files changed, 158 insertions(+), 222 deletions(-)
 delete mode 100644 lib/pleroma/web/api_spec/schemas/filter.ex
 delete mode 100644 lib/pleroma/web/api_spec/schemas/filter_create_request.ex
 delete mode 100644 lib/pleroma/web/api_spec/schemas/filter_update_request.ex
 delete mode 100644 lib/pleroma/web/api_spec/schemas/filters_response.ex

diff --git a/lib/pleroma/web/api_spec/operations/filter_operation.ex b/lib/pleroma/web/api_spec/operations/filter_operation.ex
index 0d673f566..53e57b46b 100644
--- a/lib/pleroma/web/api_spec/operations/filter_operation.ex
+++ b/lib/pleroma/web/api_spec/operations/filter_operation.ex
@@ -6,10 +6,6 @@ defmodule Pleroma.Web.ApiSpec.FilterOperation do
   alias OpenApiSpex.Operation
   alias OpenApiSpex.Schema
   alias Pleroma.Web.ApiSpec.Helpers
-  alias Pleroma.Web.ApiSpec.Schemas.Filter
-  alias Pleroma.Web.ApiSpec.Schemas.FilterCreateRequest
-  alias Pleroma.Web.ApiSpec.Schemas.FiltersResponse
-  alias Pleroma.Web.ApiSpec.Schemas.FilterUpdateRequest
 
   def open_api_operation(action) do
     operation = String.to_existing_atom("#{action}_operation")
@@ -23,7 +19,7 @@ defmodule Pleroma.Web.ApiSpec.FilterOperation do
       operationId: "FilterController.index",
       security: [%{"oAuth" => ["read:filters"]}],
       responses: %{
-        200 => Operation.response("Filters", "application/json", FiltersResponse)
+        200 => Operation.response("Filters", "application/json", array_of_filters())
       }
     }
   end
@@ -33,9 +29,9 @@ defmodule Pleroma.Web.ApiSpec.FilterOperation do
       tags: ["apps"],
       summary: "Create a filter",
       operationId: "FilterController.create",
-      requestBody: Helpers.request_body("Parameters", FilterCreateRequest, required: true),
+      requestBody: Helpers.request_body("Parameters", create_request(), required: true),
       security: [%{"oAuth" => ["write:filters"]}],
-      responses: %{200 => Operation.response("Filter", "application/json", Filter)}
+      responses: %{200 => Operation.response("Filter", "application/json", filter())}
     }
   end
 
@@ -47,7 +43,7 @@ defmodule Pleroma.Web.ApiSpec.FilterOperation do
       operationId: "FilterController.show",
       security: [%{"oAuth" => ["read:filters"]}],
       responses: %{
-        200 => Operation.response("Filter", "application/json", Filter)
+        200 => Operation.response("Filter", "application/json", filter())
       }
     }
   end
@@ -58,10 +54,10 @@ defmodule Pleroma.Web.ApiSpec.FilterOperation do
       summary: "Update a filter",
       parameters: [id_param()],
       operationId: "FilterController.update",
-      requestBody: Helpers.request_body("Parameters", FilterUpdateRequest, required: true),
+      requestBody: Helpers.request_body("Parameters", update_request(), required: true),
       security: [%{"oAuth" => ["write:filters"]}],
       responses: %{
-        200 => Operation.response("Filter", "application/json", Filter)
+        200 => Operation.response("Filter", "application/json", filter())
       }
     }
   end
@@ -86,4 +82,146 @@ defmodule Pleroma.Web.ApiSpec.FilterOperation do
   defp id_param do
     Operation.parameter(:id, :path, :string, "Filter ID", example: "123", required: true)
   end
+
+  defp filter do
+    %Schema{
+      title: "Filter",
+      type: :object,
+      properties: %{
+        id: %Schema{type: :string},
+        phrase: %Schema{type: :string, description: "The text to be filtered"},
+        context: %Schema{
+          type: :array,
+          items: %Schema{type: :string, enum: ["home", "notifications", "public", "thread"]},
+          description: "The contexts in which the filter should be applied."
+        },
+        expires_at: %Schema{
+          type: :string,
+          format: :"date-time",
+          description:
+            "When the filter should no longer be applied. String (ISO 8601 Datetime), or null if the filter does not expire.",
+          nullable: true
+        },
+        irreversible: %Schema{
+          type: :boolean,
+          description:
+            "Should matching entities in home and notifications be dropped by the server?"
+        },
+        whole_word: %Schema{
+          type: :boolean,
+          description: "Should the filter consider word boundaries?"
+        }
+      },
+      example: %{
+        "id" => "5580",
+        "phrase" => "@twitter.com",
+        "context" => [
+          "home",
+          "notifications",
+          "public",
+          "thread"
+        ],
+        "whole_word" => false,
+        "expires_at" => nil,
+        "irreversible" => true
+      }
+    }
+  end
+
+  defp array_of_filters do
+    %Schema{
+      title: "ArrayOfFilters",
+      description: "Array of Filters",
+      type: :array,
+      items: filter(),
+      example: [
+        %{
+          "id" => "5580",
+          "phrase" => "@twitter.com",
+          "context" => [
+            "home",
+            "notifications",
+            "public",
+            "thread"
+          ],
+          "whole_word" => false,
+          "expires_at" => nil,
+          "irreversible" => true
+        },
+        %{
+          "id" => "6191",
+          "phrase" => ":eurovision2019:",
+          "context" => [
+            "home"
+          ],
+          "whole_word" => true,
+          "expires_at" => "2019-05-21T13:47:31.333Z",
+          "irreversible" => false
+        }
+      ]
+    }
+  end
+
+  defp create_request do
+    %Schema{
+      title: "FilterCreateRequest",
+      allOf: [
+        update_request(),
+        %Schema{
+          type: :object,
+          properties: %{
+            irreversible: %Schema{
+              type: :bolean,
+              description:
+                "Should the server irreversibly drop matching entities from home and notifications?",
+              default: false
+            }
+          }
+        }
+      ],
+      example: %{
+        "phrase" => "knights",
+        "context" => ["home"]
+      }
+    }
+  end
+
+  defp update_request do
+    %Schema{
+      title: "FilterUpdateRequest",
+      type: :object,
+      properties: %{
+        phrase: %Schema{type: :string, description: "The text to be filtered"},
+        context: %Schema{
+          type: :array,
+          items: %Schema{type: :string, enum: ["home", "notifications", "public", "thread"]},
+          description:
+            "Array of enumerable strings `home`, `notifications`, `public`, `thread`. At least one context must be specified."
+        },
+        irreversible: %Schema{
+          type: :bolean,
+          description:
+            "Should the server irreversibly drop matching entities from home and notifications?"
+        },
+        whole_word: %Schema{
+          type: :bolean,
+          description: "Consider word boundaries?",
+          default: true
+        }
+        # TODO: probably should implement filter expiration
+        # expires_in: %Schema{
+        #   type: :string,
+        #   format: :"date-time",
+        #   description:
+        #     "ISO 8601 Datetime for when the filter expires. Otherwise,
+        #  null for a filter that doesn't expire."
+        # }
+      },
+      required: [:phrase, :context],
+      example: %{
+        "phrase" => "knights",
+        "context" => ["home"]
+      }
+    }
+  end
 end
diff --git a/lib/pleroma/web/api_spec/schemas/filter.ex b/lib/pleroma/web/api_spec/schemas/filter.ex
deleted file mode 100644
index fc5480b71..000000000
--- a/lib/pleroma/web/api_spec/schemas/filter.ex
+++ /dev/null
@@ -1,51 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.ApiSpec.Schemas.Filter do
-  alias OpenApiSpex.Schema
-  require OpenApiSpex
-
-  OpenApiSpex.schema(%{
-    title: "Filter",
-    type: :object,
-    properties: %{
-      id: %Schema{type: :string},
-      phrase: %Schema{type: :string, description: "The text to be filtered"},
-      context: %Schema{
-        type: :array,
-        items: %Schema{type: :string, enum: ["home", "notifications", "public", "thread"]},
-        description: "The contexts in which the filter should be applied."
-      },
-      expires_at: %Schema{
-        type: :string,
-        format: :"date-time",
-        description:
-          "When the filter should no longer be applied. String (ISO 8601 Datetime), or null if the filter does not expire.",
-        nullable: true
-      },
-      irreversible: %Schema{
-        type: :boolean,
-        description:
-          "Should matching entities in home and notifications be dropped by the server?"
-      },
-      whole_word: %Schema{
-        type: :boolean,
-        description: "Should the filter consider word boundaries?"
-      }
-    },
-    example: %{
-      "id" => "5580",
-      "phrase" => "@twitter.com",
-      "context" => [
-        "home",
-        "notifications",
-        "public",
-        "thread"
-      ],
-      "whole_word" => false,
-      "expires_at" => nil,
-      "irreversible" => true
-    }
-  })
-end
diff --git a/lib/pleroma/web/api_spec/schemas/filter_create_request.ex b/lib/pleroma/web/api_spec/schemas/filter_create_request.ex
deleted file mode 100644
index f2a475b12..000000000
--- a/lib/pleroma/web/api_spec/schemas/filter_create_request.ex
+++ /dev/null
@@ -1,30 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.ApiSpec.Schemas.FilterCreateRequest do
-  alias OpenApiSpex.Schema
-  require OpenApiSpex
-
-  OpenApiSpex.schema(%{
-    title: "FilterCreateRequest",
-    allOf: [
-      %OpenApiSpex.Reference{"$ref": "#/components/schemas/FilterUpdateRequest"},
-      %Schema{
-        type: :object,
-        properties: %{
-          irreversible: %Schema{
-            type: :bolean,
-            description:
-              "Should the server irreversibly drop matching entities from home and notifications?",
-            default: false
-          }
-        }
-      }
-    ],
-    example: %{
-      "phrase" => "knights",
-      "context" => ["home"]
-    }
-  })
-end
diff --git a/lib/pleroma/web/api_spec/schemas/filter_update_request.ex b/lib/pleroma/web/api_spec/schemas/filter_update_request.ex
deleted file mode 100644
index e703db0ce..000000000
--- a/lib/pleroma/web/api_spec/schemas/filter_update_request.ex
+++ /dev/null
@@ -1,41 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.ApiSpec.Schemas.FilterUpdateRequest do
-  alias OpenApiSpex.Schema
-  require OpenApiSpex
-
-  OpenApiSpex.schema(%{
-    title: "FilterUpdateRequest",
-    type: :object,
-    properties: %{
-      phrase: %Schema{type: :string, description: "The text to be filtered"},
-      context: %Schema{
-        type: :array,
-        items: %Schema{type: :string, enum: ["home", "notifications", "public", "thread"]},
-        description:
-          "Array of enumerable strings `home`, `notifications`, `public`, `thread`. At least one context must be specified."
-      },
-      irreversible: %Schema{
-        type: :bolean,
-        description:
-          "Should the server irreversibly drop matching entities from home and notifications?"
-      },
-      whole_word: %Schema{type: :bolean, description: "Consider word boundaries?", default: true}
-      # TODO: probably should implement filter expiration
-      # expires_in: %Schema{
-      #   type: :string,
-      #   format: :"date-time",
-      #   description:
-      #     "ISO 8601 Datetime for when the filter expires. Otherwise,
-      #  null for a filter that doesn't expire."
-      # }
-    },
-    required: [:phrase, :context],
-    example: %{
-      "phrase" => "knights",
-      "context" => ["home"]
-    }
-  })
-end
diff --git a/lib/pleroma/web/api_spec/schemas/filters_response.ex b/lib/pleroma/web/api_spec/schemas/filters_response.ex
deleted file mode 100644
index 8c56c5982..000000000
--- a/lib/pleroma/web/api_spec/schemas/filters_response.ex
+++ /dev/null
@@ -1,40 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.ApiSpec.Schemas.FiltersResponse do
-  require OpenApiSpex
-  alias Pleroma.Web.ApiSpec.Schemas.Filter
-
-  OpenApiSpex.schema(%{
-    title: "FiltersResponse",
-    description: "Array of Filters",
-    type: :array,
-    items: Filter,
-    example: [
-      %{
-        "id" => "5580",
-        "phrase" => "@twitter.com",
-        "context" => [
-          "home",
-          "notifications",
-          "public",
-          "thread"
-        ],
-        "whole_word" => false,
-        "expires_at" => nil,
-        "irreversible" => true
-      },
-      %{
-        "id" => "6191",
-        "phrase" => ":eurovision2019:",
-        "context" => [
-          "home"
-        ],
-        "whole_word" => true,
-        "expires_at" => "2019-05-21T13:47:31.333Z",
-        "irreversible" => false
-      }
-    ]
-  })
-end
diff --git a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex
index dd13a8a09..21dc374cd 100644
--- a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex
@@ -35,7 +35,7 @@ defmodule Pleroma.Web.MastodonAPI.FilterController do
       context: params.context,
       hide: params.irreversible,
       whole_word: params.whole_word
-      # expires_at
+      # TODO: support `expires_in` parameter (as in Mastodon API)
     }
 
     {:ok, response} = Filter.create(query)
@@ -57,13 +57,12 @@ defmodule Pleroma.Web.MastodonAPI.FilterController do
       ) do
     params =
       params
-      |> Map.from_struct()
       |> Map.delete(:irreversible)
-      |> Map.put(:hide, params.irreversible)
+      |> Map.put(:hide, params[:irreversible])
       |> Enum.reject(fn {_key, value} -> is_nil(value) end)
       |> Map.new()
 
-    # TODO: add expires_in -> expires_at
+    # TODO: support `expires_in` parameter (as in Mastodon API)
 
     with %Filter{} = filter <- Filter.get(filter_id, user),
          {:ok, %Filter{} = filter} <- Filter.update(filter, params) do
diff --git a/lib/pleroma/web/mastodon_api/views/filter_view.ex b/lib/pleroma/web/mastodon_api/views/filter_view.ex
index 8d5c381ec..aeff646f5 100644
--- a/lib/pleroma/web/mastodon_api/views/filter_view.ex
+++ b/lib/pleroma/web/mastodon_api/views/filter_view.ex
@@ -7,8 +7,8 @@ defmodule Pleroma.Web.MastodonAPI.FilterView do
   alias Pleroma.Web.CommonAPI.Utils
   alias Pleroma.Web.MastodonAPI.FilterView
 
-  def render("index.json", %{filters: filters} = opts) do
-    render_many(filters, FilterView, "show.json", opts)
+  def render("index.json", %{filters: filters}) do
+    render_many(filters, FilterView, "show.json")
   end
 
   def render("show.json", %{filter: filter}) do
diff --git a/test/web/mastodon_api/controllers/filter_controller_test.exs b/test/web/mastodon_api/controllers/filter_controller_test.exs
index 41a290eb2..f29547d13 100644
--- a/test/web/mastodon_api/controllers/filter_controller_test.exs
+++ b/test/web/mastodon_api/controllers/filter_controller_test.exs
@@ -5,15 +5,8 @@
 defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
   use Pleroma.Web.ConnCase
 
-  alias Pleroma.Web.ApiSpec
-  alias Pleroma.Web.ApiSpec.Schemas.Filter
-  alias Pleroma.Web.ApiSpec.Schemas.FilterCreateRequest
-  alias Pleroma.Web.ApiSpec.Schemas.FiltersResponse
-  alias Pleroma.Web.ApiSpec.Schemas.FilterUpdateRequest
   alias Pleroma.Web.MastodonAPI.FilterView
 
-  import OpenApiSpex.TestAssertions
-
   test "creating a filter" do
     %{conn: conn} = oauth_access(["write:filters"])
 
@@ -27,13 +20,12 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
       |> put_req_header("content-type", "application/json")
       |> post("/api/v1/filters", %{"phrase" => filter.phrase, context: filter.context})
 
-    assert response = json_response(conn, 200)
+    assert response = json_response_and_validate_schema(conn, 200)
     assert response["phrase"] == filter.phrase
     assert response["context"] == filter.context
     assert response["irreversible"] == false
     assert response["id"] != nil
     assert response["id"] != ""
-    assert_schema(response, "Filter", ApiSpec.spec())
   end
 
   test "fetching a list of filters" do
@@ -59,7 +51,7 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
     response =
       conn
       |> get("/api/v1/filters")
-      |> json_response(200)
+      |> json_response_and_validate_schema(200)
 
     assert response ==
              render_json(
@@ -67,8 +59,6 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
                "index.json",
                filters: [filter_two, filter_one]
              )
-
-    assert_schema(response, "FiltersResponse", ApiSpec.spec())
   end
 
   test "get a filter" do
@@ -85,8 +75,7 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
 
     conn = get(conn, "/api/v1/filters/#{filter.filter_id}")
 
-    assert response = json_response(conn, 200)
-    assert_schema(response, "Filter", ApiSpec.spec())
+    assert response = json_response_and_validate_schema(conn, 200)
   end
 
   test "update a filter" do
@@ -115,11 +104,10 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
         context: new.context
       })
 
-    assert response = json_response(conn, 200)
+    assert response = json_response_and_validate_schema(conn, 200)
     assert response["phrase"] == new.phrase
     assert response["context"] == new.context
     assert response["irreversible"] == true
-    assert_schema(response, "Filter", ApiSpec.spec())
   end
 
   test "delete a filter" do
@@ -136,33 +124,6 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
 
     conn = delete(conn, "/api/v1/filters/#{filter.filter_id}")
 
-    assert response = json_response(conn, 200)
-    assert response == %{}
-  end
-
-  describe "OpenAPI" do
-    test "Filter example matches schema" do
-      api_spec = ApiSpec.spec()
-      schema = Filter.schema()
-      assert_schema(schema.example, "Filter", api_spec)
-    end
-
-    test "FiltersResponse example matches schema" do
-      api_spec = ApiSpec.spec()
-      schema = FiltersResponse.schema()
-      assert_schema(schema.example, "FiltersResponse", api_spec)
-    end
-
-    test "FilterCreateRequest example matches schema" do
-      api_spec = ApiSpec.spec()
-      schema = FilterCreateRequest.schema()
-      assert_schema(schema.example, "FilterCreateRequest", api_spec)
-    end
-
-    test "FilterUpdateRequest example matches schema" do
-      api_spec = ApiSpec.spec()
-      schema = FilterUpdateRequest.schema()
-      assert_schema(schema.example, "FilterUpdateRequest", api_spec)
-    end
+    assert json_response_and_validate_schema(conn, 200) == %{}
   end
 end

From 32ca9f2c59369c15905f665bee3c759ae963ff91 Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Tue, 28 Apr 2020 16:25:13 +0400
Subject: [PATCH 65/76] Render mastodon-like errors in FilterController

---
 lib/pleroma/web/mastodon_api/controllers/filter_controller.ex | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex
index 21dc374cd..abbf0ce02 100644
--- a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex
@@ -17,7 +17,7 @@ defmodule Pleroma.Web.MastodonAPI.FilterController do
     OAuthScopesPlug,
     %{scopes: ["write:filters"]} when action not in @oauth_read_actions
   )
-  
+
   defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.FilterOperation
 
   @doc "GET /api/v1/filters"

From 3a45952a3a324e5fb823e9bdf3ffe19fb3923cb3 Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Tue, 5 May 2020 17:44:46 +0400
Subject: [PATCH 66/76] Add OpenAPI spec for ConversationController

---
 lib/pleroma/conversation/participation.ex     |  4 +-
 .../operations/conversation_operation.ex      | 61 +++++++++++++++++++
 .../web/api_spec/schemas/conversation.ex      | 41 +++++++++++++
 lib/pleroma/web/api_spec/schemas/status.ex    |  7 ++-
 .../controllers/conversation_controller.ex    |  5 +-
 .../conversation_controller_test.exs          | 22 +++----
 6 files changed, 125 insertions(+), 15 deletions(-)
 create mode 100644 lib/pleroma/web/api_spec/operations/conversation_operation.ex
 create mode 100644 lib/pleroma/web/api_spec/schemas/conversation.ex

diff --git a/lib/pleroma/conversation/participation.ex b/lib/pleroma/conversation/participation.ex
index 215265fc9..51bb1bda9 100644
--- a/lib/pleroma/conversation/participation.ex
+++ b/lib/pleroma/conversation/participation.ex
@@ -128,7 +128,7 @@ defmodule Pleroma.Conversation.Participation do
     |> Pleroma.Pagination.fetch_paginated(params)
   end
 
-  def restrict_recipients(query, user, %{"recipients" => user_ids}) do
+  def restrict_recipients(query, user, %{recipients: user_ids}) do
     user_binary_ids =
       [user.id | user_ids]
       |> Enum.uniq()
@@ -172,7 +172,7 @@ defmodule Pleroma.Conversation.Participation do
         | last_activity_id: activity_id
       }
     end)
-    |> Enum.filter(& &1.last_activity_id)
+    |> Enum.reject(&is_nil(&1.last_activity_id))
   end
 
   def get(_, _ \\ [])
diff --git a/lib/pleroma/web/api_spec/operations/conversation_operation.ex b/lib/pleroma/web/api_spec/operations/conversation_operation.ex
new file mode 100644
index 000000000..475468893
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/conversation_operation.ex
@@ -0,0 +1,61 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.ConversationOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Schemas.Conversation
+  alias Pleroma.Web.ApiSpec.Schemas.FlakeID
+
+  import Pleroma.Web.ApiSpec.Helpers
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def index_operation do
+    %Operation{
+      tags: ["Conversations"],
+      summary: "Show conversation",
+      security: [%{"oAuth" => ["read:statuses"]}],
+      operationId: "ConversationController.index",
+      parameters: [
+        Operation.parameter(
+          :recipients,
+          :query,
+          %Schema{type: :array, items: FlakeID},
+          "Only return conversations with the given recipients (a list of user ids)"
+        )
+        | pagination_params()
+      ],
+      responses: %{
+        200 =>
+          Operation.response("Array of Conversation", "application/json", %Schema{
+            type: :array,
+            items: Conversation,
+            example: [Conversation.schema().example]
+          })
+      }
+    }
+  end
+
+  def mark_as_read_operation do
+    %Operation{
+      tags: ["Conversations"],
+      summary: "Mark as read",
+      operationId: "ConversationController.mark_as_read",
+      parameters: [
+        Operation.parameter(:id, :path, :string, "Conversation ID",
+          example: "123",
+          required: true
+        )
+      ],
+      security: [%{"oAuth" => ["write:conversations"]}],
+      responses: %{
+        200 => Operation.response("Conversation", "application/json", Conversation)
+      }
+    }
+  end
+end
diff --git a/lib/pleroma/web/api_spec/schemas/conversation.ex b/lib/pleroma/web/api_spec/schemas/conversation.ex
new file mode 100644
index 000000000..d8ff5ba26
--- /dev/null
+++ b/lib/pleroma/web/api_spec/schemas/conversation.ex
@@ -0,0 +1,41 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Schemas.Conversation do
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Schemas.Account
+  alias Pleroma.Web.ApiSpec.Schemas.Status
+
+  require OpenApiSpex
+
+  OpenApiSpex.schema(%{
+    title: "Conversation",
+    description: "Represents a conversation with \"direct message\" visibility.",
+    type: :object,
+    required: [:id, :accounts, :unread],
+    properties: %{
+      id: %Schema{type: :string},
+      accounts: %Schema{
+        type: :array,
+        items: Account,
+        description: "Participants in the conversation"
+      },
+      unread: %Schema{
+        type: :boolean,
+        description: "Is the conversation currently marked as unread?"
+      },
+      # last_status: Status
+      last_status: %Schema{
+        allOf: [Status],
+        description: "The last status in the conversation, to be used for optional display"
+      }
+    },
+    example: %{
+      "id" => "418450",
+      "unread" => true,
+      "accounts" => [Account.schema().example],
+      "last_status" => Status.schema().example
+    }
+  })
+end
diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex
index aef0588d4..42e9dae19 100644
--- a/lib/pleroma/web/api_spec/schemas/status.ex
+++ b/lib/pleroma/web/api_spec/schemas/status.ex
@@ -86,7 +86,12 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
         properties: %{
           content: %Schema{type: :object, additionalProperties: %Schema{type: :string}},
           conversation_id: %Schema{type: :integer},
-          direct_conversation_id: %Schema{type: :string, nullable: true},
+          direct_conversation_id: %Schema{
+            type: :integer,
+            nullable: true,
+            description:
+              "The ID of the Mastodon direct message conversation the status is associated with (if any)"
+          },
           emoji_reactions: %Schema{
             type: :array,
             items: %Schema{
diff --git a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex
index c44641526..f35ec3596 100644
--- a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex
@@ -13,9 +13,12 @@ defmodule Pleroma.Web.MastodonAPI.ConversationController do
 
   action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
 
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
   plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action == :index)
   plug(OAuthScopesPlug, %{scopes: ["write:conversations"]} when action != :index)
 
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ConversationOperation
+
   @doc "GET /api/v1/conversations"
   def index(%{assigns: %{user: user}} = conn, params) do
     participations = Participation.for_user_with_last_activity_id(user, params)
@@ -26,7 +29,7 @@ defmodule Pleroma.Web.MastodonAPI.ConversationController do
   end
 
   @doc "POST /api/v1/conversations/:id/read"
-  def mark_as_read(%{assigns: %{user: user}} = conn, %{"id" => participation_id}) do
+  def mark_as_read(%{assigns: %{user: user}} = conn, %{id: participation_id}) do
     with %Participation{} = participation <-
            Repo.get_by(Participation, id: participation_id, user_id: user.id),
          {:ok, participation} <- Participation.mark_as_read(participation) do
diff --git a/test/web/mastodon_api/controllers/conversation_controller_test.exs b/test/web/mastodon_api/controllers/conversation_controller_test.exs
index 801b0259b..04695572e 100644
--- a/test/web/mastodon_api/controllers/conversation_controller_test.exs
+++ b/test/web/mastodon_api/controllers/conversation_controller_test.exs
@@ -36,7 +36,7 @@ defmodule Pleroma.Web.MastodonAPI.ConversationControllerTest do
 
     res_conn = get(conn, "/api/v1/conversations")
 
-    assert response = json_response(res_conn, 200)
+    assert response = json_response_and_validate_schema(res_conn, 200)
 
     assert [
              %{
@@ -91,18 +91,18 @@ defmodule Pleroma.Web.MastodonAPI.ConversationControllerTest do
         "visibility" => "direct"
       })
 
-    [conversation1, conversation2] =
-      conn
-      |> get("/api/v1/conversations", %{"recipients" => [user_two.id]})
-      |> json_response(200)
+    assert [conversation1, conversation2] =
+             conn
+             |> get("/api/v1/conversations?recipients[]=#{user_two.id}")
+             |> json_response_and_validate_schema(200)
 
     assert conversation1["last_status"]["id"] == direct5.id
     assert conversation2["last_status"]["id"] == direct1.id
 
     [conversation1] =
       conn
-      |> get("/api/v1/conversations", %{"recipients" => [user_two.id, user_three.id]})
-      |> json_response(200)
+      |> get("/api/v1/conversations?recipients[]=#{user_two.id}&recipients[]=#{user_three.id}")
+      |> json_response_and_validate_schema(200)
 
     assert conversation1["last_status"]["id"] == direct3.id
   end
@@ -126,7 +126,7 @@ defmodule Pleroma.Web.MastodonAPI.ConversationControllerTest do
     [%{"last_status" => res_last_status}] =
       conn
       |> get("/api/v1/conversations")
-      |> json_response(200)
+      |> json_response_and_validate_schema(200)
 
     assert res_last_status["id"] == direct_reply.id
   end
@@ -154,12 +154,12 @@ defmodule Pleroma.Web.MastodonAPI.ConversationControllerTest do
     [%{"id" => direct_conversation_id, "unread" => true}] =
       user_two_conn
       |> get("/api/v1/conversations")
-      |> json_response(200)
+      |> json_response_and_validate_schema(200)
 
     %{"unread" => false} =
       user_two_conn
       |> post("/api/v1/conversations/#{direct_conversation_id}/read")
-      |> json_response(200)
+      |> json_response_and_validate_schema(200)
 
     assert User.get_cached_by_id(user_one.id).unread_conversation_count == 0
     assert User.get_cached_by_id(user_two.id).unread_conversation_count == 0
@@ -175,7 +175,7 @@ defmodule Pleroma.Web.MastodonAPI.ConversationControllerTest do
     [%{"unread" => true}] =
       conn
       |> get("/api/v1/conversations")
-      |> json_response(200)
+      |> json_response_and_validate_schema(200)
 
     assert User.get_cached_by_id(user_one.id).unread_conversation_count == 1
     assert User.get_cached_by_id(user_two.id).unread_conversation_count == 0

From 0a1394cc1a38ce66b1b30d728856ae891aa3d7b0 Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Tue, 5 May 2020 20:14:22 +0400
Subject: [PATCH 67/76] Add OpenAPI spec for PollController

---
 .../web/api_spec/operations/poll_operation.ex | 76 +++++++++++++++++++
 lib/pleroma/web/api_spec/schemas/poll.ex      | 62 +++++++++++++--
 .../controllers/poll_controller.ex            |  8 +-
 .../controllers/poll_controller_test.exs      | 38 +++++++---
 4 files changed, 162 insertions(+), 22 deletions(-)
 create mode 100644 lib/pleroma/web/api_spec/operations/poll_operation.ex

diff --git a/lib/pleroma/web/api_spec/operations/poll_operation.ex b/lib/pleroma/web/api_spec/operations/poll_operation.ex
new file mode 100644
index 000000000..b953323e9
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/poll_operation.ex
@@ -0,0 +1,76 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.PollOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Schemas.ApiError
+  alias Pleroma.Web.ApiSpec.Schemas.FlakeID
+  alias Pleroma.Web.ApiSpec.Schemas.Poll
+
+  import Pleroma.Web.ApiSpec.Helpers
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def show_operation do
+    %Operation{
+      tags: ["Polls"],
+      summary: "View a poll",
+      security: [%{"oAuth" => ["read:statuses"]}],
+      parameters: [id_param()],
+      operationId: "PollController.show",
+      responses: %{
+        200 => Operation.response("Poll", "application/json", Poll),
+        404 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  def vote_operation do
+    %Operation{
+      tags: ["Polls"],
+      summary: "Block a domain",
+      parameters: [id_param()],
+      operationId: "PollController.vote",
+      requestBody: vote_request(),
+      security: [%{"oAuth" => ["write:statuses"]}],
+      responses: %{
+        200 => Operation.response("Poll", "application/json", Poll),
+        422 => Operation.response("Error", "application/json", ApiError),
+        404 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  defp id_param do
+    Operation.parameter(:id, :path, FlakeID, "Poll ID",
+      example: "123",
+      required: true
+    )
+  end
+
+  defp vote_request do
+    request_body(
+      "Parameters",
+      %Schema{
+        type: :object,
+        properties: %{
+          choices: %Schema{
+            type: :array,
+            items: %Schema{type: :integer},
+            description: "Array of own votes containing index for each option (starting from 0)"
+          }
+        },
+        required: [:choices]
+      },
+      required: true,
+      example: %{
+        "choices" => [0, 1, 2]
+      }
+    )
+  end
+end
diff --git a/lib/pleroma/web/api_spec/schemas/poll.ex b/lib/pleroma/web/api_spec/schemas/poll.ex
index 0474b550b..c62096db0 100644
--- a/lib/pleroma/web/api_spec/schemas/poll.ex
+++ b/lib/pleroma/web/api_spec/schemas/poll.ex
@@ -11,26 +11,72 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Poll do
 
   OpenApiSpex.schema(%{
     title: "Poll",
-    description: "Response schema for account custom fields",
+    description: "Represents a poll attached to a status",
     type: :object,
     properties: %{
       id: FlakeID,
-      expires_at: %Schema{type: :string, format: "date-time"},
-      expired: %Schema{type: :boolean},
-      multiple: %Schema{type: :boolean},
-      votes_count: %Schema{type: :integer},
-      voted: %Schema{type: :boolean},
-      emojis: %Schema{type: :array, items: Emoji},
+      expires_at: %Schema{
+        type: :string,
+        format: :"date-time",
+        nullable: true,
+        description: "When the poll ends"
+      },
+      expired: %Schema{type: :boolean, description: "Is the poll currently expired?"},
+      multiple: %Schema{
+        type: :boolean,
+        description: "Does the poll allow multiple-choice answers?"
+      },
+      votes_count: %Schema{
+        type: :integer,
+        nullable: true,
+        description: "How many votes have been received. Number, or null if `multiple` is false."
+      },
+      voted: %Schema{
+        type: :boolean,
+        nullable: true,
+        description:
+          "When called with a user token, has the authorized user voted? Boolean, or null if no current user."
+      },
+      emojis: %Schema{
+        type: :array,
+        items: Emoji,
+        description: "Custom emoji to be used for rendering poll options."
+      },
       options: %Schema{
         type: :array,
         items: %Schema{
+          title: "PollOption",
           type: :object,
           properties: %{
             title: %Schema{type: :string},
             votes_count: %Schema{type: :integer}
           }
-        }
+        },
+        description: "Possible answers for the poll."
       }
+    },
+    example: %{
+      id: "34830",
+      expires_at: "2019-12-05T04:05:08.302Z",
+      expired: true,
+      multiple: false,
+      votes_count: 10,
+      voters_count: nil,
+      voted: true,
+      own_votes: [
+        1
+      ],
+      options: [
+        %{
+          title: "accept",
+          votes_count: 6
+        },
+        %{
+          title: "deny",
+          votes_count: 4
+        }
+      ],
+      emojis: []
     }
   })
 end
diff --git a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex
index af9b66eff..db46ffcfc 100644
--- a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex
@@ -15,6 +15,8 @@ defmodule Pleroma.Web.MastodonAPI.PollController do
 
   action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
 
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
+
   plug(
     OAuthScopesPlug,
     %{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} when action == :show
@@ -22,8 +24,10 @@ defmodule Pleroma.Web.MastodonAPI.PollController do
 
   plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :vote)
 
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PollOperation
+
   @doc "GET /api/v1/polls/:id"
-  def show(%{assigns: %{user: user}} = conn, %{"id" => id}) do
+  def show(%{assigns: %{user: user}} = conn, %{id: id}) do
     with %Object{} = object <- Object.get_by_id_and_maybe_refetch(id, interval: 60),
          %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]),
          true <- Visibility.visible_for_user?(activity, user) do
@@ -35,7 +39,7 @@ defmodule Pleroma.Web.MastodonAPI.PollController do
   end
 
   @doc "POST /api/v1/polls/:id/votes"
-  def vote(%{assigns: %{user: user}} = conn, %{"id" => id, "choices" => choices}) do
+  def vote(%{assigns: %{user: user}, body_params: %{choices: choices}} = conn, %{id: id}) do
     with %Object{data: %{"type" => "Question"}} = object <- Object.get_by_id(id),
          %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]),
          true <- Visibility.visible_for_user?(activity, user),
diff --git a/test/web/mastodon_api/controllers/poll_controller_test.exs b/test/web/mastodon_api/controllers/poll_controller_test.exs
index 88b13a25a..d8f34aa86 100644
--- a/test/web/mastodon_api/controllers/poll_controller_test.exs
+++ b/test/web/mastodon_api/controllers/poll_controller_test.exs
@@ -24,7 +24,7 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do
 
       conn = get(conn, "/api/v1/polls/#{object.id}")
 
-      response = json_response(conn, 200)
+      response = json_response_and_validate_schema(conn, 200)
       id = to_string(object.id)
       assert %{"id" => ^id, "expired" => false, "multiple" => false} = response
     end
@@ -43,7 +43,7 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do
 
       conn = get(conn, "/api/v1/polls/#{object.id}")
 
-      assert json_response(conn, 404)
+      assert json_response_and_validate_schema(conn, 404)
     end
   end
 
@@ -65,9 +65,12 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do
 
       object = Object.normalize(activity)
 
-      conn = post(conn, "/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1, 2]})
+      conn =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1, 2]})
 
-      assert json_response(conn, 200)
+      assert json_response_and_validate_schema(conn, 200)
       object = Object.get_by_id(object.id)
 
       assert Enum.all?(object.data["anyOf"], fn %{"replies" => %{"totalItems" => total_items}} ->
@@ -85,8 +88,9 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do
       object = Object.normalize(activity)
 
       assert conn
+             |> put_req_header("content-type", "application/json")
              |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [1]})
-             |> json_response(422) == %{"error" => "Poll's author can't vote"}
+             |> json_response_and_validate_schema(422) == %{"error" => "Poll's author can't vote"}
 
       object = Object.get_by_id(object.id)
 
@@ -105,8 +109,9 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do
       object = Object.normalize(activity)
 
       assert conn
+             |> put_req_header("content-type", "application/json")
              |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1]})
-             |> json_response(422) == %{"error" => "Too many choices"}
+             |> json_response_and_validate_schema(422) == %{"error" => "Too many choices"}
 
       object = Object.get_by_id(object.id)
 
@@ -126,15 +131,21 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do
 
       object = Object.normalize(activity)
 
-      conn = post(conn, "/api/v1/polls/#{object.id}/votes", %{"choices" => [2]})
+      conn =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [2]})
 
-      assert json_response(conn, 422) == %{"error" => "Invalid indices"}
+      assert json_response_and_validate_schema(conn, 422) == %{"error" => "Invalid indices"}
     end
 
     test "returns 404 error when object is not exist", %{conn: conn} do
-      conn = post(conn, "/api/v1/polls/1/votes", %{"choices" => [0]})
+      conn =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/v1/polls/1/votes", %{"choices" => [0]})
 
-      assert json_response(conn, 404) == %{"error" => "Record not found"}
+      assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"}
     end
 
     test "returns 404 when poll is private and not available for user", %{conn: conn} do
@@ -149,9 +160,12 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do
 
       object = Object.normalize(activity)
 
-      conn = post(conn, "/api/v1/polls/#{object.id}/votes", %{"choices" => [0]})
+      conn =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0]})
 
-      assert json_response(conn, 404) == %{"error" => "Record not found"}
+      assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"}
     end
   end
 end

From 6ba25d11973e56008e5d674313421197ff418d6d Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Tue, 5 May 2020 23:19:16 +0400
Subject: [PATCH 68/76] Add Attachment schema

---
 .../web/api_spec/schemas/attachment.ex        | 68 +++++++++++++++++++
 .../web/api_spec/schemas/scheduled_status.ex  | 53 +++++++++++++++
 lib/pleroma/web/api_spec/schemas/status.ex    | 18 +----
 3 files changed, 123 insertions(+), 16 deletions(-)
 create mode 100644 lib/pleroma/web/api_spec/schemas/attachment.ex
 create mode 100644 lib/pleroma/web/api_spec/schemas/scheduled_status.ex

diff --git a/lib/pleroma/web/api_spec/schemas/attachment.ex b/lib/pleroma/web/api_spec/schemas/attachment.ex
new file mode 100644
index 000000000..c146c416e
--- /dev/null
+++ b/lib/pleroma/web/api_spec/schemas/attachment.ex
@@ -0,0 +1,68 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Schemas.Attachment do
+  alias OpenApiSpex.Schema
+
+  require OpenApiSpex
+
+  OpenApiSpex.schema(%{
+    title: "Attachment",
+    description: "Represents a file or media attachment that can be added to a status.",
+    type: :object,
+    requried: [:id, :url, :preview_url],
+    properties: %{
+      id: %Schema{type: :string},
+      url: %Schema{
+        type: :string,
+        format: :uri,
+        description: "The location of the original full-size attachment"
+      },
+      remote_url: %Schema{
+        type: :string,
+        format: :uri,
+        description:
+          "The location of the full-size original attachment on the remote website. String (URL), or null if the attachment is local",
+        nullable: true
+      },
+      preview_url: %Schema{
+        type: :string,
+        format: :uri,
+        description: "The location of a scaled-down preview of the attachment"
+      },
+      text_url: %Schema{
+        type: :string,
+        format: :uri,
+        description: "A shorter URL for the attachment"
+      },
+      description: %Schema{
+        type: :string,
+        nullable: true,
+        description:
+          "Alternate text that describes what is in the media attachment, to be used for the visually impaired or when media attachments do not load"
+      },
+      type: %Schema{
+        type: :string,
+        enum: ["image", "video", "audio", "unknown"],
+        description: "The type of the attachment"
+      },
+      pleroma: %Schema{
+        type: :object,
+        properties: %{
+          mime_type: %Schema{type: :string, description: "mime type of the attachment"}
+        }
+      }
+    },
+    example: %{
+      id: "1638338801",
+      type: "image",
+      url: "someurl",
+      remote_url: "someurl",
+      preview_url: "someurl",
+      text_url: "someurl",
+      description: nil,
+      pleroma: %{mime_type: "image/png"}
+    }
+  })
+end
diff --git a/lib/pleroma/web/api_spec/schemas/scheduled_status.ex b/lib/pleroma/web/api_spec/schemas/scheduled_status.ex
new file mode 100644
index 000000000..f0bc4ee3c
--- /dev/null
+++ b/lib/pleroma/web/api_spec/schemas/scheduled_status.ex
@@ -0,0 +1,53 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Schemas.ScheduledStatus do
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
+  alias Pleroma.Web.ApiSpec.Schemas.Poll
+
+  require OpenApiSpex
+
+  OpenApiSpex.schema(%{
+    title: "ScheduledStatus",
+    description: "Represents a status that will be published at a future scheduled date.",
+    type: :object,
+    required: [:id, :scheduled_at, :params],
+    properties: %{
+      id: %Schema{type: :string},
+      scheduled_at: %Schema{type: :string, format: :"date-time"},
+      media_attachments: %Schema{type: :array, format: :"date-time"},
+      params: %Schema{
+        type: :object,
+        required: [:text, :visibility],
+        properties: %{
+          text: %Schema{type: :string, nullable: true},
+          media_ids: %Schema{type: :array, nullable: true, items: %Schema{type: :string}},
+          sensitive: %Schema{type: :boolean, nullable: true},
+          spoiler_text: %Schema{type: :string, nullable: true},
+          visibility: %Schema{type: VisibilityScope, nullable: true},
+          scheduled_at: %Schema{type: :string, format: :"date-time", nullable: true},
+          poll: %Schema{type: Poll, nullable: true},
+          in_reply_to_id: %Schema{type: :string, nullable: true}
+        }
+      }
+    },
+    example: %{
+      id: "3221",
+      scheduled_at: "2019-12-05T12:33:01.000Z",
+      params: %{
+        text: "test content",
+        media_ids: nil,
+        sensitive: nil,
+        spoiler_text: nil,
+        visibility: nil,
+        scheduled_at: nil,
+        poll: nil,
+        idempotency: nil,
+        in_reply_to_id: nil
+      },
+      media_attachments: []
+    }
+  })
+end
diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex
index aef0588d4..d44636a48 100644
--- a/lib/pleroma/web/api_spec/schemas/status.ex
+++ b/lib/pleroma/web/api_spec/schemas/status.ex
@@ -5,6 +5,7 @@
 defmodule Pleroma.Web.ApiSpec.Schemas.Status do
   alias OpenApiSpex.Schema
   alias Pleroma.Web.ApiSpec.Schemas.Account
+  alias Pleroma.Web.ApiSpec.Schemas.Attachment
   alias Pleroma.Web.ApiSpec.Schemas.Emoji
   alias Pleroma.Web.ApiSpec.Schemas.FlakeID
   alias Pleroma.Web.ApiSpec.Schemas.Poll
@@ -50,22 +51,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
       language: %Schema{type: :string, nullable: true},
       media_attachments: %Schema{
         type: :array,
-        items: %Schema{
-          type: :object,
-          properties: %{
-            id: %Schema{type: :string},
-            url: %Schema{type: :string, format: :uri},
-            remote_url: %Schema{type: :string, format: :uri},
-            preview_url: %Schema{type: :string, format: :uri},
-            text_url: %Schema{type: :string, format: :uri},
-            description: %Schema{type: :string},
-            type: %Schema{type: :string, enum: ["image", "video", "audio", "unknown"]},
-            pleroma: %Schema{
-              type: :object,
-              properties: %{mime_type: %Schema{type: :string}}
-            }
-          }
-        }
+        items: Attachment
       },
       mentions: %Schema{
         type: :array,

From 332e016bcdbda5dca90d916bc62a9c67544b5323 Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Tue, 5 May 2020 23:42:18 +0400
Subject: [PATCH 69/76] Add OpenAPI spec for ScheduledActivityController

---
 .../scheduled_activity_operation.ex           | 96 +++++++++++++++++++
 .../web/api_spec/schemas/scheduled_status.ex  |  7 +-
 .../scheduled_activity_controller.ex          | 12 ++-
 test/support/helpers.ex                       |  8 +-
 .../scheduled_activity_controller_test.exs    | 34 ++++---
 .../mastodon_api/views/status_view_test.exs   |  8 +-
 6 files changed, 144 insertions(+), 21 deletions(-)
 create mode 100644 lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex

diff --git a/lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex b/lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex
new file mode 100644
index 000000000..fe675a923
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex
@@ -0,0 +1,96 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.ScheduledActivityOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Schemas.ApiError
+  alias Pleroma.Web.ApiSpec.Schemas.FlakeID
+  alias Pleroma.Web.ApiSpec.Schemas.ScheduledStatus
+
+  import Pleroma.Web.ApiSpec.Helpers
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def index_operation do
+    %Operation{
+      tags: ["Scheduled Statuses"],
+      summary: "View scheduled statuses",
+      security: [%{"oAuth" => ["read:statuses"]}],
+      parameters: pagination_params(),
+      operationId: "ScheduledActivity.index",
+      responses: %{
+        200 =>
+          Operation.response("Array of ScheduledStatus", "application/json", %Schema{
+            type: :array,
+            items: ScheduledStatus
+          })
+      }
+    }
+  end
+
+  def show_operation do
+    %Operation{
+      tags: ["Scheduled Statuses"],
+      summary: "View a single scheduled status",
+      security: [%{"oAuth" => ["read:statuses"]}],
+      parameters: [id_param()],
+      operationId: "ScheduledActivity.show",
+      responses: %{
+        200 => Operation.response("Scheduled Status", "application/json", ScheduledStatus),
+        404 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  def update_operation do
+    %Operation{
+      tags: ["Scheduled Statuses"],
+      summary: "Schedule a status",
+      operationId: "ScheduledActivity.update",
+      security: [%{"oAuth" => ["write:statuses"]}],
+      parameters: [id_param()],
+      requestBody:
+        request_body("Parameters", %Schema{
+          type: :object,
+          properties: %{
+            scheduled_at: %Schema{
+              type: :string,
+              format: :"date-time",
+              description:
+                "ISO 8601 Datetime at which the status will be published. Must be at least 5 minutes into the future."
+            }
+          }
+        }),
+      responses: %{
+        200 => Operation.response("Scheduled Status", "application/json", ScheduledStatus),
+        404 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  def delete_operation do
+    %Operation{
+      tags: ["Scheduled Statuses"],
+      summary: "Cancel a scheduled status",
+      security: [%{"oAuth" => ["write:statuses"]}],
+      parameters: [id_param()],
+      operationId: "ScheduledActivity.delete",
+      responses: %{
+        200 => Operation.response("Empty object", "application/json", %Schema{type: :object}),
+        404 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  defp id_param do
+    Operation.parameter(:id, :path, FlakeID, "Poll ID",
+      example: "123",
+      required: true
+    )
+  end
+end
diff --git a/lib/pleroma/web/api_spec/schemas/scheduled_status.ex b/lib/pleroma/web/api_spec/schemas/scheduled_status.ex
index f0bc4ee3c..0520d0848 100644
--- a/lib/pleroma/web/api_spec/schemas/scheduled_status.ex
+++ b/lib/pleroma/web/api_spec/schemas/scheduled_status.ex
@@ -4,8 +4,9 @@
 
 defmodule Pleroma.Web.ApiSpec.Schemas.ScheduledStatus do
   alias OpenApiSpex.Schema
-  alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
+  alias Pleroma.Web.ApiSpec.Schemas.Attachment
   alias Pleroma.Web.ApiSpec.Schemas.Poll
+  alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
 
   require OpenApiSpex
 
@@ -17,7 +18,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ScheduledStatus do
     properties: %{
       id: %Schema{type: :string},
       scheduled_at: %Schema{type: :string, format: :"date-time"},
-      media_attachments: %Schema{type: :array, format: :"date-time"},
+      media_attachments: %Schema{type: :array, items: Attachment},
       params: %Schema{
         type: :object,
         required: [:text, :visibility],
@@ -47,7 +48,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ScheduledStatus do
         idempotency: nil,
         in_reply_to_id: nil
       },
-      media_attachments: []
+      media_attachments: [Attachment.schema().example]
     }
   })
 end
diff --git a/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex
index 899b78873..1719c67ea 100644
--- a/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex
@@ -11,17 +11,21 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityController do
   alias Pleroma.ScheduledActivity
   alias Pleroma.Web.MastodonAPI.MastodonAPI
 
-  plug(:assign_scheduled_activity when action != :index)
-
   @oauth_read_actions [:show, :index]
 
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
   plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in @oauth_read_actions)
   plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action not in @oauth_read_actions)
+  plug(:assign_scheduled_activity when action != :index)
 
   action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
 
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ScheduledActivityOperation
+
   @doc "GET /api/v1/scheduled_statuses"
   def index(%{assigns: %{user: user}} = conn, params) do
+    params = Map.new(params, fn {key, value} -> {to_string(key), value} end)
+
     with scheduled_activities <- MastodonAPI.get_scheduled_activities(user, params) do
       conn
       |> add_link_headers(scheduled_activities)
@@ -35,7 +39,7 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityController do
   end
 
   @doc "PUT /api/v1/scheduled_statuses/:id"
-  def update(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, params) do
+  def update(%{assigns: %{scheduled_activity: scheduled_activity}, body_params: params} = conn, _) do
     with {:ok, scheduled_activity} <- ScheduledActivity.update(scheduled_activity, params) do
       render(conn, "show.json", scheduled_activity: scheduled_activity)
     end
@@ -48,7 +52,7 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityController do
     end
   end
 
-  defp assign_scheduled_activity(%{assigns: %{user: user}, params: %{"id" => id}} = conn, _) do
+  defp assign_scheduled_activity(%{assigns: %{user: user}, params: %{id: id}} = conn, _) do
     case ScheduledActivity.get(user, id) do
       %ScheduledActivity{} = activity -> assign(conn, :scheduled_activity, activity)
       nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt()
diff --git a/test/support/helpers.ex b/test/support/helpers.ex
index e68e9bfd2..26281b45e 100644
--- a/test/support/helpers.ex
+++ b/test/support/helpers.ex
@@ -40,12 +40,18 @@ defmodule Pleroma.Tests.Helpers do
           clear_config: 2
         ]
 
-      def to_datetime(naive_datetime) do
+      def to_datetime(%NaiveDateTime{} = naive_datetime) do
         naive_datetime
         |> DateTime.from_naive!("Etc/UTC")
         |> DateTime.truncate(:second)
       end
 
+      def to_datetime(datetime) when is_binary(datetime) do
+        datetime
+        |> NaiveDateTime.from_iso8601!()
+        |> to_datetime()
+      end
+
       def collect_ids(collection) do
         collection
         |> Enum.map(& &1.id)
diff --git a/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs b/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs
index f86274d57..1ff871c89 100644
--- a/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs
+++ b/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs
@@ -24,19 +24,19 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityControllerTest do
     # min_id
     conn_res = get(conn, "/api/v1/scheduled_statuses?limit=2&min_id=#{scheduled_activity_id1}")
 
-    result = json_response(conn_res, 200)
+    result = json_response_and_validate_schema(conn_res, 200)
     assert [%{"id" => ^scheduled_activity_id3}, %{"id" => ^scheduled_activity_id2}] = result
 
     # since_id
     conn_res = get(conn, "/api/v1/scheduled_statuses?limit=2&since_id=#{scheduled_activity_id1}")
 
-    result = json_response(conn_res, 200)
+    result = json_response_and_validate_schema(conn_res, 200)
     assert [%{"id" => ^scheduled_activity_id4}, %{"id" => ^scheduled_activity_id3}] = result
 
     # max_id
     conn_res = get(conn, "/api/v1/scheduled_statuses?limit=2&max_id=#{scheduled_activity_id4}")
 
-    result = json_response(conn_res, 200)
+    result = json_response_and_validate_schema(conn_res, 200)
     assert [%{"id" => ^scheduled_activity_id3}, %{"id" => ^scheduled_activity_id2}] = result
   end
 
@@ -46,12 +46,12 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityControllerTest do
 
     res_conn = get(conn, "/api/v1/scheduled_statuses/#{scheduled_activity.id}")
 
-    assert %{"id" => scheduled_activity_id} = json_response(res_conn, 200)
+    assert %{"id" => scheduled_activity_id} = json_response_and_validate_schema(res_conn, 200)
     assert scheduled_activity_id == scheduled_activity.id |> to_string()
 
     res_conn = get(conn, "/api/v1/scheduled_statuses/404")
 
-    assert %{"error" => "Record not found"} = json_response(res_conn, 404)
+    assert %{"error" => "Record not found"} = json_response_and_validate_schema(res_conn, 404)
   end
 
   test "updates a scheduled activity" do
@@ -74,22 +74,32 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityControllerTest do
     assert job.args == %{"activity_id" => scheduled_activity.id}
     assert DateTime.truncate(job.scheduled_at, :second) == to_datetime(scheduled_at)
 
-    new_scheduled_at = Timex.shift(NaiveDateTime.utc_now(), minutes: 120)
+    new_scheduled_at =
+      NaiveDateTime.utc_now()
+      |> Timex.shift(minutes: 120)
+      |> Timex.format!("%Y-%m-%dT%H:%M:%S.%fZ", :strftime)
 
     res_conn =
-      put(conn, "/api/v1/scheduled_statuses/#{scheduled_activity.id}", %{
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> put("/api/v1/scheduled_statuses/#{scheduled_activity.id}", %{
         scheduled_at: new_scheduled_at
       })
 
-    assert %{"scheduled_at" => expected_scheduled_at} = json_response(res_conn, 200)
+    assert %{"scheduled_at" => expected_scheduled_at} =
+             json_response_and_validate_schema(res_conn, 200)
+
     assert expected_scheduled_at == Pleroma.Web.CommonAPI.Utils.to_masto_date(new_scheduled_at)
     job = refresh_record(job)
 
     assert DateTime.truncate(job.scheduled_at, :second) == to_datetime(new_scheduled_at)
 
-    res_conn = put(conn, "/api/v1/scheduled_statuses/404", %{scheduled_at: new_scheduled_at})
+    res_conn =
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> put("/api/v1/scheduled_statuses/404", %{scheduled_at: new_scheduled_at})
 
-    assert %{"error" => "Record not found"} = json_response(res_conn, 404)
+    assert %{"error" => "Record not found"} = json_response_and_validate_schema(res_conn, 404)
   end
 
   test "deletes a scheduled activity" do
@@ -115,7 +125,7 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityControllerTest do
       |> assign(:user, user)
       |> delete("/api/v1/scheduled_statuses/#{scheduled_activity.id}")
 
-    assert %{} = json_response(res_conn, 200)
+    assert %{} = json_response_and_validate_schema(res_conn, 200)
     refute Repo.get(ScheduledActivity, scheduled_activity.id)
     refute Repo.get(Oban.Job, job.id)
 
@@ -124,6 +134,6 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityControllerTest do
       |> assign(:user, user)
       |> delete("/api/v1/scheduled_statuses/#{scheduled_activity.id}")
 
-    assert %{"error" => "Record not found"} = json_response(res_conn, 404)
+    assert %{"error" => "Record not found"} = json_response_and_validate_schema(res_conn, 404)
   end
 end
diff --git a/test/web/mastodon_api/views/status_view_test.exs b/test/web/mastodon_api/views/status_view_test.exs
index 6791c2fb0..451723e60 100644
--- a/test/web/mastodon_api/views/status_view_test.exs
+++ b/test/web/mastodon_api/views/status_view_test.exs
@@ -402,11 +402,17 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
       pleroma: %{mime_type: "image/png"}
     }
 
+    api_spec = Pleroma.Web.ApiSpec.spec()
+
     assert expected == StatusView.render("attachment.json", %{attachment: object})
+    OpenApiSpex.TestAssertions.assert_schema(expected, "Attachment", api_spec)
 
     # If theres a "id", use that instead of the generated one
     object = Map.put(object, "id", 2)
-    assert %{id: "2"} = StatusView.render("attachment.json", %{attachment: object})
+    result = StatusView.render("attachment.json", %{attachment: object})
+
+    assert %{id: "2"} = result
+    OpenApiSpex.TestAssertions.assert_schema(result, "Attachment", api_spec)
   end
 
   test "put the url advertised in the Activity in to the url attribute" do

From 06c69c0a0a03d7797213fc520b6bf24fab65a7e3 Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Wed, 6 May 2020 14:18:19 +0400
Subject: [PATCH 70/76] Fix description

---
 lib/pleroma/web/api_spec/operations/poll_operation.ex | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/lib/pleroma/web/api_spec/operations/poll_operation.ex b/lib/pleroma/web/api_spec/operations/poll_operation.ex
index b953323e9..e15c7dc95 100644
--- a/lib/pleroma/web/api_spec/operations/poll_operation.ex
+++ b/lib/pleroma/web/api_spec/operations/poll_operation.ex
@@ -33,7 +33,7 @@ defmodule Pleroma.Web.ApiSpec.PollOperation do
   def vote_operation do
     %Operation{
       tags: ["Polls"],
-      summary: "Block a domain",
+      summary: "Vote on a poll",
       parameters: [id_param()],
       operationId: "PollController.vote",
       requestBody: vote_request(),

From 3c42caa85c51b4eaa447d6aafcfaa0bfceaa9beb Mon Sep 17 00:00:00 2001
From: Alexander Strizhakov <alex.strizhakov@gmail.com>
Date: Wed, 6 May 2020 16:20:47 +0300
Subject: [PATCH 71/76] apache chain issue fix

---
 installation/pleroma-apache.conf | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/installation/pleroma-apache.conf b/installation/pleroma-apache.conf
index b5640ac3d..0d627f2d7 100644
--- a/installation/pleroma-apache.conf
+++ b/installation/pleroma-apache.conf
@@ -32,9 +32,8 @@ CustomLog ${APACHE_LOG_DIR}/access.log combined
 
 <VirtualHost *:443>
     SSLEngine on
-    SSLCertificateFile      /etc/letsencrypt/live/${servername}/cert.pem
+    SSLCertificateFile      /etc/letsencrypt/live/${servername}/fullchain.pem
     SSLCertificateKeyFile   /etc/letsencrypt/live/${servername}/privkey.pem
-    SSLCertificateChainFile /etc/letsencrypt/live/${servername}/fullchain.pem
 
     # Mozilla modern configuration, tweak to your needs
     SSLProtocol             all -SSLv3 -TLSv1 -TLSv1.1

From d7537a37c77dfef469106f12f0dd3649aad197da Mon Sep 17 00:00:00 2001
From: Mark Felder <feld@FreeBSD.org>
Date: Wed, 6 May 2020 08:55:09 -0500
Subject: [PATCH 72/76] Add :chat to cheatsheet

---
 docs/configuration/cheatsheet.md | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md
index 705c4c15e..2524918d4 100644
--- a/docs/configuration/cheatsheet.md
+++ b/docs/configuration/cheatsheet.md
@@ -8,6 +8,10 @@ For from source installations Pleroma configuration works by first importing the
 
 To add configuration to your config file, you can copy it from the base config. The latest version of it can be viewed [here](https://git.pleroma.social/pleroma/pleroma/blob/develop/config/config.exs). You can also use this file if you don't know how an option is supposed to be formatted.
 
+## :chat
+
+* `enabled` - Enables the backend chat. Defaults to `true`.
+
 ## :instance
 * `name`: The instance’s name.
 * `email`: Email used to reach an Administrator/Moderator of the instance.

From 4b00eb93fe2cef97a5570b9cc6e6844898d31b9a Mon Sep 17 00:00:00 2001
From: Alexander Strizhakov <alex.strizhakov@gmail.com>
Date: Wed, 6 May 2020 18:04:16 +0300
Subject: [PATCH 73/76] fix for syslog compile with updated rebar3

---
 mix.lock | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/mix.lock b/mix.lock
index ee9d93bfb..28287cf97 100644
--- a/mix.lock
+++ b/mix.lock
@@ -37,7 +37,7 @@
   "ex_const": {:hex, :ex_const, "0.2.4", "d06e540c9d834865b012a17407761455efa71d0ce91e5831e86881b9c9d82448", [:mix], [], "hexpm", "96fd346610cc992b8f896ed26a98be82ac4efb065a0578f334a32d60a3ba9767"},
   "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"},
   "ex_machina": {:hex, :ex_machina, "2.3.0", "92a5ad0a8b10ea6314b876a99c8c9e3f25f4dde71a2a835845b136b9adaf199a", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "b84f6af156264530b312a8ab98ac6088f6b77ae5fe2058305c81434aa01fbaf9"},
-  "ex_syslogger": {:hex, :ex_syslogger, "1.5.0", "bc936ee3fd13d9e592cb4c3a1e8a55fccd33b05e3aa7b185f211f3ed263ff8f0", [:mix], [{:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:syslog, "~> 1.0.5", [hex: :syslog, repo: "hexpm", optional: false]}], "hexpm", "f3b4b184dcdd5f356b7c26c6cd72ab0918ba9dfb4061ccfaf519e562942af87b"},
+  "ex_syslogger": {:hex, :ex_syslogger, "1.5.2", "72b6aa2d47a236e999171f2e1ec18698740f40af0bd02c8c650bf5f1fd1bac79", [:mix], [{:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:syslog, "~> 1.1.0", [hex: :syslog, repo: "hexpm", optional: false]}], "hexpm", "ab9fab4136dbc62651ec6f16fa4842f10cf02ab4433fa3d0976c01be99398399"},
   "excoveralls": {:hex, :excoveralls, "0.12.2", "a513defac45c59e310ac42fcf2b8ae96f1f85746410f30b1ff2b710a4b6cd44b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "151c476331d49b45601ffc45f43cb3a8beb396b02a34e3777fea0ad34ae57d89"},
   "fast_html": {:hex, :fast_html, "1.0.3", "2cc0d4b68496266a1530e0c852cafeaede0bd10cfdee26fda50dc696c203162f", [:make, :mix], [], "hexpm", "ab3d782b639d3c4655fbaec0f9d032c91f8cab8dd791ac7469c2381bc7c32f85"},
   "fast_sanitize": {:hex, :fast_sanitize, "0.1.7", "2a7cd8734c88a2de6de55022104f8a3b87f1fdbe8bbf131d9049764b53d50d0d", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f39fe8ea08fbac17487c30bf09b7d9f3e12472e51fb07a88ffeb8fd17da8ab67"},
@@ -102,7 +102,7 @@
   "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"},
   "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"},
   "swoosh": {:hex, :swoosh, "0.23.5", "bfd9404bbf5069b1be2ffd317923ce57e58b332e25dbca2a35dedd7820dfee5a", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "e3928e1d2889a308aaf3e42755809ac21cffd77cb58eef01cbfdab4ce2fd1e21"},
-  "syslog": {:hex, :syslog, "1.0.6", "995970c9aa7feb380ac493302138e308d6e04fd57da95b439a6df5bb3bf75076", [:rebar3], [], "hexpm", "769ddfabd0d2a16f3f9c17eb7509951e0ca4f68363fb26f2ee51a8ec4a49881a"},
+  "syslog": {:hex, :syslog, "1.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"},
   "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"},
   "tesla": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/tesla.git", "61b7503cef33f00834f78ddfafe0d5d9dec2270b", [ref: "61b7503cef33f00834f78ddfafe0d5d9dec2270b"]},
   "timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "f354efb2400dd7a80fd9eb6c8419068c4f632da4ac47f3d8822d6e33f08bc852"},

From 3d0c567fbc3506770fdac5f1269c45b244928747 Mon Sep 17 00:00:00 2001
From: Maksim <parallel588@gmail.com>
Date: Thu, 7 May 2020 08:14:54 +0000
Subject: [PATCH 74/76] 
 Pleroma.Web.TwitterAPI.TwoFactorAuthenticationController ->
 Pleroma.Web.PleromaAPI.TwoFactorAuthenticationController

---
 config/config.exs                             |  13 +-
 config/description.exs                        |  56 ++++
 config/test.exs                               |  13 +
 docs/API/admin_api.md                         |   9 +
 docs/API/pleroma_api.md                       |  44 ++-
 docs/configuration/cheatsheet.md              |   9 +-
 lib/pleroma/mfa.ex                            | 156 +++++++++
 lib/pleroma/mfa/backup_codes.ex               |  31 ++
 lib/pleroma/mfa/changeset.ex                  |  64 ++++
 lib/pleroma/mfa/settings.ex                   |  24 ++
 lib/pleroma/mfa/token.ex                      | 106 ++++++
 lib/pleroma/mfa/totp.ex                       |  86 +++++
 .../plugs/ensure_authenticated_plug.ex        |  14 +
 lib/pleroma/user.ex                           |   8 +
 .../web/admin_api/admin_api_controller.ex     |  14 +
 lib/pleroma/web/auth/pleroma_authenticator.ex |   4 +-
 lib/pleroma/web/auth/totp_authenticator.ex    |  45 +++
 lib/pleroma/web/common_api/utils.ex           |   1 +
 lib/pleroma/web/oauth/mfa_controller.ex       |  97 ++++++
 lib/pleroma/web/oauth/mfa_view.ex             |   8 +
 lib/pleroma/web/oauth/oauth_controller.ex     |  48 ++-
 lib/pleroma/web/oauth/token/clean_worker.ex   |  38 +++
 lib/pleroma/web/oauth/token/response.ex       |   9 +
 .../two_factor_authentication_controller.ex   | 133 ++++++++
 lib/pleroma/web/router.ex                     |  15 +
 .../templates/o_auth/mfa/recovery.html.eex    |  24 ++
 .../web/templates/o_auth/mfa/totp.html.eex    |  24 ++
 .../remote_follow/follow_mfa.html.eex         |  13 +
 .../controllers/remote_follow_controller.ex   |  47 ++-
 mix.exs                                       |   1 +
 mix.lock                                      |  33 +-
 ...factor_authentication_settings_to_user.exs |   9 +
 .../20190508193213_create_mfa_tokens.exs      |  16 +
 .../static/fonts/element-icons.535877f.woff   | Bin 28200 -> 0 bytes
 .../static/fonts/element-icons.732389d.ttf    | Bin 55956 -> 0 bytes
 test/mfa/backup_codes_test.exs                |  11 +
 test/mfa/totp_test.exs                        |  17 +
 test/mfa_test.exs                             |  53 +++
 test/plugs/ensure_authenticated_plug_test.exs |  25 ++
 test/support/builders/user_builder.ex         |   1 +
 test/support/factory.ex                       |  12 +-
 test/user_search_test.exs                     |   1 +
 .../admin_api/admin_api_controller_test.exs   |  33 ++
 test/web/auth/pleroma_authenticator_test.exs  |  43 +++
 test/web/auth/totp_authenticator_test.exs     |  51 +++
 test/web/oauth/mfa_controller_test.exs        | 306 ++++++++++++++++++
 test/web/oauth/oauth_controller_test.exs      |  77 +++++
 ..._factor_authentication_controller_test.exs | 260 +++++++++++++++
 .../remote_follow_controller_test.exs         | 116 +++++++
 49 files changed, 2184 insertions(+), 34 deletions(-)
 create mode 100644 lib/pleroma/mfa.ex
 create mode 100644 lib/pleroma/mfa/backup_codes.ex
 create mode 100644 lib/pleroma/mfa/changeset.ex
 create mode 100644 lib/pleroma/mfa/settings.ex
 create mode 100644 lib/pleroma/mfa/token.ex
 create mode 100644 lib/pleroma/mfa/totp.ex
 create mode 100644 lib/pleroma/web/auth/totp_authenticator.ex
 create mode 100644 lib/pleroma/web/oauth/mfa_controller.ex
 create mode 100644 lib/pleroma/web/oauth/mfa_view.ex
 create mode 100644 lib/pleroma/web/oauth/token/clean_worker.ex
 create mode 100644 lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex
 create mode 100644 lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex
 create mode 100644 lib/pleroma/web/templates/o_auth/mfa/totp.html.eex
 create mode 100644 lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex
 create mode 100644 priv/repo/migrations/20190506054542_add_multi_factor_authentication_settings_to_user.exs
 create mode 100644 priv/repo/migrations/20190508193213_create_mfa_tokens.exs
 delete mode 100644 priv/static/adminfe/static/fonts/element-icons.535877f.woff
 delete mode 100644 priv/static/adminfe/static/fonts/element-icons.732389d.ttf
 create mode 100644 test/mfa/backup_codes_test.exs
 create mode 100644 test/mfa/totp_test.exs
 create mode 100644 test/mfa_test.exs
 create mode 100644 test/web/auth/pleroma_authenticator_test.exs
 create mode 100644 test/web/auth/totp_authenticator_test.exs
 create mode 100644 test/web/oauth/mfa_controller_test.exs
 create mode 100644 test/web/pleroma_api/controllers/two_factor_authentication_controller_test.exs

diff --git a/config/config.exs b/config/config.exs
index ca9bbab64..e703c1632 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -238,7 +238,18 @@ config :pleroma, :instance,
   account_field_value_length: 2048,
   external_user_synchronization: true,
   extended_nickname_format: true,
-  cleanup_attachments: false
+  cleanup_attachments: false,
+  multi_factor_authentication: [
+    totp: [
+      # digits 6 or 8
+      digits: 6,
+      period: 30
+    ],
+    backup_codes: [
+      number: 5,
+      length: 16
+    ]
+  ]
 
 config :pleroma, :extensions, output_relationships_in_statuses_by_default: true
 
diff --git a/config/description.exs b/config/description.exs
index 1b2afebef..39e094082 100644
--- a/config/description.exs
+++ b/config/description.exs
@@ -919,6 +919,62 @@ config :pleroma, :config_description, [
         key: :external_user_synchronization,
         type: :boolean,
         description: "Enabling following/followers counters synchronization for external users"
+      },
+      %{
+        key: :multi_factor_authentication,
+        type: :keyword,
+        description: "Multi-factor authentication settings",
+        suggestions: [
+          [
+            totp: [digits: 6, period: 30],
+            backup_codes: [number: 5, length: 16]
+          ]
+        ],
+        children: [
+          %{
+            key: :totp,
+            type: :keyword,
+            description: "TOTP settings",
+            suggestions: [digits: 6, period: 30],
+            children: [
+              %{
+                key: :digits,
+                type: :integer,
+                suggestions: [6],
+                description:
+                  "Determines the length of a one-time pass-code, in characters. Defaults to 6 characters."
+              },
+              %{
+                key: :period,
+                type: :integer,
+                suggestions: [30],
+                description:
+                  "a period for which the TOTP code will be valid, in seconds. Defaults to 30 seconds."
+              }
+            ]
+          },
+          %{
+            key: :backup_codes,
+            type: :keyword,
+            description: "MFA backup codes settings",
+            suggestions: [number: 5, length: 16],
+            children: [
+              %{
+                key: :number,
+                type: :integer,
+                suggestions: [5],
+                description: "number of backup codes to generate."
+              },
+              %{
+                key: :length,
+                type: :integer,
+                suggestions: [16],
+                description:
+                  "Determines the length of backup one-time pass-codes, in characters. Defaults to 16 characters."
+              }
+            ]
+          }
+        ]
       }
     ]
   },
diff --git a/config/test.exs b/config/test.exs
index cbf775109..e38b9967d 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -56,6 +56,19 @@ config :pleroma, :rich_media,
   ignore_hosts: [],
   ignore_tld: ["local", "localdomain", "lan"]
 
+config :pleroma, :instance,
+  multi_factor_authentication: [
+    totp: [
+      # digits 6 or 8
+      digits: 6,
+      period: 30
+    ],
+    backup_codes: [
+      number: 2,
+      length: 6
+    ]
+  ]
+
 config :web_push_encryption, :vapid_details,
   subject: "mailto:administrator@example.com",
   public_key:
diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md
index 23af08961..c455047cc 100644
--- a/docs/API/admin_api.md
+++ b/docs/API/admin_api.md
@@ -409,6 +409,7 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
 
 ### Get a password reset token for a given nickname
 
+
 - Params: none
 - Response:
 
@@ -427,6 +428,14 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
   - `nicknames`
 - Response: none (code `204`)
 
+## PUT `/api/pleroma/admin/users/disable_mfa`
+
+### Disable mfa for user's account.
+
+- Params:
+  - `nickname`
+- Response: User’s nickname
+
 ## `GET /api/pleroma/admin/users/:nickname/credentials`
 
 ### Get the user's email, password, display and settings-related fields
diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md
index b927be026..5895613a3 100644
--- a/docs/API/pleroma_api.md
+++ b/docs/API/pleroma_api.md
@@ -70,7 +70,49 @@ Request parameters can be passed via [query strings](https://en.wikipedia.org/wi
 * Response: JSON. Returns `{"status": "success"}` if the account was successfully disabled, `{"error": "[error message]"}` otherwise
 * Example response: `{"error": "Invalid password."}`
 
-## `/api/pleroma/admin/`…
+## `/api/pleroma/accounts/mfa`
+#### Gets current MFA settings
+* method: `GET`
+* Authentication: required
+* OAuth scope: `read:security`
+* Response: JSON. Returns `{"enabled": "false", "totp": false }`
+
+## `/api/pleroma/accounts/mfa/setup/totp`
+#### Pre-setup the MFA/TOTP method
+* method: `GET`
+* Authentication: required
+* OAuth scope: `write:security`
+* Response: JSON. Returns `{"key": [secret_key], "provisioning_uri": "[qr code uri]"  }` when successful, otherwise returns HTTP 422 `{"error": "error_msg"}`
+
+## `/api/pleroma/accounts/mfa/confirm/totp`
+#### Confirms & enables MFA/TOTP support for user account.
+* method: `POST`
+* Authentication: required
+* OAuth scope: `write:security`
+* Params:
+    * `password`: user's password
+    * `code`: token from TOTP App
+* Response: JSON. Returns `{}` if the enable was successful, HTTP 422 `{"error": "[error message]"}` otherwise
+
+
+## `/api/pleroma/accounts/mfa/totp`
+####  Disables MFA/TOTP method for user account.
+* method: `DELETE`
+* Authentication: required
+* OAuth scope: `write:security`
+* Params:
+    * `password`: user's password
+* Response: JSON. Returns `{}` if the disable was successful, HTTP 422 `{"error": "[error message]"}` otherwise
+* Example response: `{"error": "Invalid password."}`
+
+## `/api/pleroma/accounts/mfa/backup_codes`
+####  Generstes backup codes MFA for user account.
+* method: `GET`
+* Authentication: required
+* OAuth scope: `write:security`
+* Response: JSON. Returns `{"codes": codes}`when successful, otherwise HTTP 422 `{"error": "[error message]"}`
+
+## `/api/pleroma/admin/`
 See [Admin-API](admin_api.md)
 
 ## `/api/v1/pleroma/notifications/read`
diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md
index 2524918d4..707d7fdbd 100644
--- a/docs/configuration/cheatsheet.md
+++ b/docs/configuration/cheatsheet.md
@@ -907,12 +907,18 @@ config :auto_linker,
 
 * `runtime_dir`: A path to custom Elixir modules (such as MRF policies).
 
-
 ## :configurable_from_database
 
 Boolean, enables/disables in-database configuration. Read [Transfering the config to/from the database](../administration/CLI_tasks/config.md) for more information.
 
 
+### Multi-factor authentication -  :two_factor_authentication
+* `totp` - a list containing TOTP configuration
+  - `digits` - Determines the length of a one-time pass-code in characters. Defaults to 6 characters.
+  - `period` - a period for which the TOTP code will be valid in seconds. Defaults to 30 seconds.
+* `backup_codes` - a list containing backup codes configuration
+  - `number` - number of backup codes to generate.
+  - `length` - backup code length. Defaults to 16 characters.
 
 ## Restrict entities access for unauthenticated users
 
@@ -930,6 +936,7 @@ Restrict access for unauthenticated users to timelines (public and federate), us
   * `local`
   * `remote`
 
+
 ## Pleroma.Web.ApiSpec.CastAndValidate
 
 * `:strict` a boolean, enables strict input validation (useful in development, not recommended in production). Defaults to `false`.
diff --git a/lib/pleroma/mfa.ex b/lib/pleroma/mfa.ex
new file mode 100644
index 000000000..d353a4dad
--- /dev/null
+++ b/lib/pleroma/mfa.ex
@@ -0,0 +1,156 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.MFA do
+  @moduledoc """
+  The MFA context.
+  """
+
+  alias Comeonin.Pbkdf2
+  alias Pleroma.User
+
+  alias Pleroma.MFA.BackupCodes
+  alias Pleroma.MFA.Changeset
+  alias Pleroma.MFA.Settings
+  alias Pleroma.MFA.TOTP
+
+  @doc """
+  Returns MFA methods the user has enabled.
+
+  ## Examples
+
+    iex> Pleroma.MFA.supported_method(User)
+    "totp, u2f"
+  """
+  @spec supported_methods(User.t()) :: String.t()
+  def supported_methods(user) do
+    settings = fetch_settings(user)
+
+    Settings.mfa_methods()
+    |> Enum.reduce([], fn m, acc ->
+      if method_enabled?(m, settings) do
+        acc ++ [m]
+      else
+        acc
+      end
+    end)
+    |> Enum.join(",")
+  end
+
+  @doc "Checks that user enabled MFA"
+  def require?(user) do
+    fetch_settings(user).enabled
+  end
+
+  @doc """
+  Display MFA settings of user
+  """
+  def mfa_settings(user) do
+    settings = fetch_settings(user)
+
+    Settings.mfa_methods()
+    |> Enum.map(fn m -> [m, method_enabled?(m, settings)] end)
+    |> Enum.into(%{enabled: settings.enabled}, fn [a, b] -> {a, b} end)
+  end
+
+  @doc false
+  def fetch_settings(%User{} = user) do
+    user.multi_factor_authentication_settings || %Settings{}
+  end
+
+  @doc "clears backup codes"
+  def invalidate_backup_code(%User{} = user, hash_code) do
+    %{backup_codes: codes} = fetch_settings(user)
+
+    user
+    |> Changeset.cast_backup_codes(codes -- [hash_code])
+    |> User.update_and_set_cache()
+  end
+
+  @doc "generates backup codes"
+  @spec generate_backup_codes(User.t()) :: {:ok, list(binary)} | {:error, String.t()}
+  def generate_backup_codes(%User{} = user) do
+    with codes <- BackupCodes.generate(),
+         hashed_codes <- Enum.map(codes, &Pbkdf2.hashpwsalt/1),
+         changeset <- Changeset.cast_backup_codes(user, hashed_codes),
+         {:ok, _} <- User.update_and_set_cache(changeset) do
+      {:ok, codes}
+    else
+      {:error, msg} ->
+        %{error: msg}
+    end
+  end
+
+  @doc """
+  Generates secret key and set delivery_type to 'app' for TOTP method.
+  """
+  @spec setup_totp(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
+  def setup_totp(user) do
+    user
+    |> Changeset.setup_totp(%{secret: TOTP.generate_secret(), delivery_type: "app"})
+    |> User.update_and_set_cache()
+  end
+
+  @doc """
+  Confirms the TOTP method for user.
+
+  `attrs`:
+    `password` - current user password
+    `code` - TOTP token
+  """
+  @spec confirm_totp(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t() | atom()}
+  def confirm_totp(%User{} = user, attrs) do
+    with settings <- user.multi_factor_authentication_settings.totp,
+         {:ok, :pass} <- TOTP.validate_token(settings.secret, attrs["code"]) do
+      user
+      |> Changeset.confirm_totp()
+      |> User.update_and_set_cache()
+    end
+  end
+
+  @doc """
+  Disables the TOTP method for user.
+
+  `attrs`:
+    `password` - current user password
+  """
+  @spec disable_totp(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
+  def disable_totp(%User{} = user) do
+    user
+    |> Changeset.disable_totp()
+    |> Changeset.disable()
+    |> User.update_and_set_cache()
+  end
+
+  @doc """
+  Force disables all MFA methods for user.
+  """
+  @spec disable(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
+  def disable(%User{} = user) do
+    user
+    |> Changeset.disable_totp()
+    |> Changeset.disable(true)
+    |> User.update_and_set_cache()
+  end
+
+  @doc """
+  Checks if the user has MFA method enabled.
+  """
+  def method_enabled?(method, settings) do
+    with {:ok, %{confirmed: true} = _} <- Map.fetch(settings, method) do
+      true
+    else
+      _ -> false
+    end
+  end
+
+  @doc """
+  Checks if the user has enabled at least one MFA method.
+  """
+  def enabled?(settings) do
+    Settings.mfa_methods()
+    |> Enum.map(fn m -> method_enabled?(m, settings) end)
+    |> Enum.any?()
+  end
+end
diff --git a/lib/pleroma/mfa/backup_codes.ex b/lib/pleroma/mfa/backup_codes.ex
new file mode 100644
index 000000000..2b5ec34f8
--- /dev/null
+++ b/lib/pleroma/mfa/backup_codes.ex
@@ -0,0 +1,31 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.MFA.BackupCodes do
+  @moduledoc """
+  This module contains functions for generating backup codes.
+  """
+  alias Pleroma.Config
+
+  @config_ns [:instance, :multi_factor_authentication, :backup_codes]
+
+  @doc """
+  Generates backup codes.
+  """
+  @spec generate(Keyword.t()) :: list(String.t())
+  def generate(opts \\ []) do
+    number_of_codes = Keyword.get(opts, :number_of_codes, default_backup_codes_number())
+    code_length = Keyword.get(opts, :length, default_backup_codes_code_length())
+
+    Enum.map(1..number_of_codes, fn _ ->
+      :crypto.strong_rand_bytes(div(code_length, 2))
+      |> Base.encode16(case: :lower)
+    end)
+  end
+
+  defp default_backup_codes_number, do: Config.get(@config_ns ++ [:number], 5)
+
+  defp default_backup_codes_code_length,
+    do: Config.get(@config_ns ++ [:length], 16)
+end
diff --git a/lib/pleroma/mfa/changeset.ex b/lib/pleroma/mfa/changeset.ex
new file mode 100644
index 000000000..9b020aa8e
--- /dev/null
+++ b/lib/pleroma/mfa/changeset.ex
@@ -0,0 +1,64 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.MFA.Changeset do
+  alias Pleroma.MFA
+  alias Pleroma.MFA.Settings
+  alias Pleroma.User
+
+  def disable(%Ecto.Changeset{} = changeset, force \\ false) do
+    settings =
+      changeset
+      |> Ecto.Changeset.apply_changes()
+      |> MFA.fetch_settings()
+
+    if force || not MFA.enabled?(settings) do
+      put_change(changeset, %Settings{settings | enabled: false})
+    else
+      changeset
+    end
+  end
+
+  def disable_totp(%User{multi_factor_authentication_settings: settings} = user) do
+    user
+    |> put_change(%Settings{settings | totp: %Settings.TOTP{}})
+  end
+
+  def confirm_totp(%User{multi_factor_authentication_settings: settings} = user) do
+    totp_settings = %Settings.TOTP{settings.totp | confirmed: true}
+
+    user
+    |> put_change(%Settings{settings | totp: totp_settings, enabled: true})
+  end
+
+  def setup_totp(%User{} = user, attrs) do
+    mfa_settings = MFA.fetch_settings(user)
+
+    totp_settings =
+      %Settings.TOTP{}
+      |> Ecto.Changeset.cast(attrs, [:secret, :delivery_type])
+
+    user
+    |> put_change(%Settings{mfa_settings | totp: Ecto.Changeset.apply_changes(totp_settings)})
+  end
+
+  def cast_backup_codes(%User{} = user, codes) do
+    user
+    |> put_change(%Settings{
+      user.multi_factor_authentication_settings
+      | backup_codes: codes
+    })
+  end
+
+  defp put_change(%User{} = user, settings) do
+    user
+    |> Ecto.Changeset.change()
+    |> put_change(settings)
+  end
+
+  defp put_change(%Ecto.Changeset{} = changeset, settings) do
+    changeset
+    |> Ecto.Changeset.put_change(:multi_factor_authentication_settings, settings)
+  end
+end
diff --git a/lib/pleroma/mfa/settings.ex b/lib/pleroma/mfa/settings.ex
new file mode 100644
index 000000000..2764b889c
--- /dev/null
+++ b/lib/pleroma/mfa/settings.ex
@@ -0,0 +1,24 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.MFA.Settings do
+  use Ecto.Schema
+
+  @primary_key false
+
+  @mfa_methods [:totp]
+  embedded_schema do
+    field(:enabled, :boolean, default: false)
+    field(:backup_codes, {:array, :string}, default: [])
+
+    embeds_one :totp, TOTP, on_replace: :delete, primary_key: false do
+      field(:secret, :string)
+      # app | sms
+      field(:delivery_type, :string, default: "app")
+      field(:confirmed, :boolean, default: false)
+    end
+  end
+
+  def mfa_methods, do: @mfa_methods
+end
diff --git a/lib/pleroma/mfa/token.ex b/lib/pleroma/mfa/token.ex
new file mode 100644
index 000000000..25ff7fb29
--- /dev/null
+++ b/lib/pleroma/mfa/token.ex
@@ -0,0 +1,106 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.MFA.Token do
+  use Ecto.Schema
+  import Ecto.Query
+  import Ecto.Changeset
+
+  alias Pleroma.Repo
+  alias Pleroma.User
+  alias Pleroma.Web.OAuth.Authorization
+  alias Pleroma.Web.OAuth.Token, as: OAuthToken
+
+  @expires 300
+
+  schema "mfa_tokens" do
+    field(:token, :string)
+    field(:valid_until, :naive_datetime_usec)
+
+    belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
+    belongs_to(:authorization, Authorization)
+
+    timestamps()
+  end
+
+  def get_by_token(token) do
+    from(
+      t in __MODULE__,
+      where: t.token == ^token,
+      preload: [:user, :authorization]
+    )
+    |> Repo.find_resource()
+  end
+
+  def validate(token) do
+    with {:fetch_token, {:ok, token}} <- {:fetch_token, get_by_token(token)},
+         {:expired, false} <- {:expired, is_expired?(token)} do
+      {:ok, token}
+    else
+      {:expired, _} -> {:error, :expired_token}
+      {:fetch_token, _} -> {:error, :not_found}
+      error -> {:error, error}
+    end
+  end
+
+  def create_token(%User{} = user) do
+    %__MODULE__{}
+    |> change
+    |> assign_user(user)
+    |> put_token
+    |> put_valid_until
+    |> Repo.insert()
+  end
+
+  def create_token(user, authorization) do
+    %__MODULE__{}
+    |> change
+    |> assign_user(user)
+    |> assign_authorization(authorization)
+    |> put_token
+    |> put_valid_until
+    |> Repo.insert()
+  end
+
+  defp assign_user(changeset, user) do
+    changeset
+    |> put_assoc(:user, user)
+    |> validate_required([:user])
+  end
+
+  defp assign_authorization(changeset, authorization) do
+    changeset
+    |> put_assoc(:authorization, authorization)
+    |> validate_required([:authorization])
+  end
+
+  defp put_token(changeset) do
+    changeset
+    |> change(%{token: OAuthToken.Utils.generate_token()})
+    |> validate_required([:token])
+    |> unique_constraint(:token)
+  end
+
+  defp put_valid_until(changeset) do
+    expires_in = NaiveDateTime.add(NaiveDateTime.utc_now(), @expires)
+
+    changeset
+    |> change(%{valid_until: expires_in})
+    |> validate_required([:valid_until])
+  end
+
+  def is_expired?(%__MODULE__{valid_until: valid_until}) do
+    NaiveDateTime.diff(NaiveDateTime.utc_now(), valid_until) > 0
+  end
+
+  def is_expired?(_), do: false
+
+  def delete_expired_tokens do
+    from(
+      q in __MODULE__,
+      where: fragment("?", q.valid_until) < ^Timex.now()
+    )
+    |> Repo.delete_all()
+  end
+end
diff --git a/lib/pleroma/mfa/totp.ex b/lib/pleroma/mfa/totp.ex
new file mode 100644
index 000000000..1407afc57
--- /dev/null
+++ b/lib/pleroma/mfa/totp.ex
@@ -0,0 +1,86 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.MFA.TOTP do
+  @moduledoc """
+  This module represents functions to create secrets for
+  TOTP Application as well as validate them with a time based token.
+  """
+  alias Pleroma.Config
+
+  @config_ns [:instance, :multi_factor_authentication, :totp]
+
+  @doc """
+  https://github.com/google/google-authenticator/wiki/Key-Uri-Format
+  """
+  def provisioning_uri(secret, label, opts \\ []) do
+    query =
+      %{
+        secret: secret,
+        issuer: Keyword.get(opts, :issuer, default_issuer()),
+        digits: Keyword.get(opts, :digits, default_digits()),
+        period: Keyword.get(opts, :period, default_period())
+      }
+      |> Enum.filter(fn {_, v} -> not is_nil(v) end)
+      |> Enum.into(%{})
+      |> URI.encode_query()
+
+    %URI{scheme: "otpauth", host: "totp", path: "/" <> label, query: query}
+    |> URI.to_string()
+  end
+
+  defp default_period, do: Config.get(@config_ns ++ [:period])
+  defp default_digits, do: Config.get(@config_ns ++ [:digits])
+
+  defp default_issuer,
+    do: Config.get(@config_ns ++ [:issuer], Config.get([:instance, :name]))
+
+  @doc "Creates a random Base 32 encoded string"
+  def generate_secret do
+    Base.encode32(:crypto.strong_rand_bytes(10))
+  end
+
+  @doc "Generates a valid token based on a secret"
+  def generate_token(secret) do
+    :pot.totp(secret)
+  end
+
+  @doc """
+  Validates a given token based on a secret.
+
+  optional parameters:
+  `token_length` default `6`
+  `interval_length` default `30`
+  `window` default 0
+
+  Returns {:ok, :pass} if the token is valid and
+  {:error, :invalid_token} if it is not.
+  """
+  @spec validate_token(String.t(), String.t()) ::
+          {:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token}
+  def validate_token(secret, token)
+      when is_binary(secret) and is_binary(token) do
+    opts = [
+      token_length: default_digits(),
+      interval_length: default_period()
+    ]
+
+    validate_token(secret, token, opts)
+  end
+
+  def validate_token(_, _), do: {:error, :invalid_secret_and_token}
+
+  @doc "See `validate_token/2`"
+  @spec validate_token(String.t(), String.t(), Keyword.t()) ::
+          {:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token}
+  def validate_token(secret, token, options)
+      when is_binary(secret) and is_binary(token) do
+    case :pot.valid_totp(token, secret, options) do
+      true -> {:ok, :pass}
+      false -> {:error, :invalid_token}
+    end
+  end
+
+  def validate_token(_, _, _), do: {:error, :invalid_secret_and_token}
+end
diff --git a/lib/pleroma/plugs/ensure_authenticated_plug.ex b/lib/pleroma/plugs/ensure_authenticated_plug.ex
index 9d5176e2b..3fe550806 100644
--- a/lib/pleroma/plugs/ensure_authenticated_plug.ex
+++ b/lib/pleroma/plugs/ensure_authenticated_plug.ex
@@ -15,6 +15,20 @@ defmodule Pleroma.Plugs.EnsureAuthenticatedPlug do
   end
 
   @impl true
+  def perform(
+        %{
+          assigns: %{
+            auth_credentials: %{password: _},
+            user: %User{multi_factor_authentication_settings: %{enabled: true}}
+          }
+        } = conn,
+        _
+      ) do
+    conn
+    |> render_error(:forbidden, "Two-factor authentication enabled, you must use a access token.")
+    |> halt()
+  end
+
   def perform(%{assigns: %{user: %User{}}} = conn, _) do
     conn
   end
diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex
index 323eb2a41..a6f51f0be 100644
--- a/lib/pleroma/user.ex
+++ b/lib/pleroma/user.ex
@@ -20,6 +20,7 @@ defmodule Pleroma.User do
   alias Pleroma.Formatter
   alias Pleroma.HTML
   alias Pleroma.Keys
+  alias Pleroma.MFA
   alias Pleroma.Notification
   alias Pleroma.Object
   alias Pleroma.Registration
@@ -190,6 +191,12 @@ defmodule Pleroma.User do
     # `:subscribers` is deprecated (replaced with `subscriber_users` relation)
     field(:subscribers, {:array, :string}, default: [])
 
+    embeds_one(
+      :multi_factor_authentication_settings,
+      MFA.Settings,
+      on_replace: :delete
+    )
+
     timestamps()
   end
 
@@ -927,6 +934,7 @@ defmodule Pleroma.User do
     end
   end
 
+  @spec get_by_nickname(String.t()) :: User.t() | nil
   def get_by_nickname(nickname) do
     Repo.get_by(User, nickname: nickname) ||
       if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()})i, nickname) do
diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex
index 80a4ebaac..9f1fd3aeb 100644
--- a/lib/pleroma/web/admin_api/admin_api_controller.ex
+++ b/lib/pleroma/web/admin_api/admin_api_controller.ex
@@ -10,6 +10,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
   alias Pleroma.Activity
   alias Pleroma.Config
   alias Pleroma.ConfigDB
+  alias Pleroma.MFA
   alias Pleroma.ModerationLog
   alias Pleroma.Plugs.OAuthScopesPlug
   alias Pleroma.ReportNote
@@ -61,6 +62,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
            :right_add,
            :right_add_multiple,
            :right_delete,
+           :disable_mfa,
            :right_delete_multiple,
            :update_user_credentials
          ]
@@ -674,6 +676,18 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
     json_response(conn, :no_content, "")
   end
 
+  @doc "Disable mfa for user's account."
+  def disable_mfa(conn, %{"nickname" => nickname}) do
+    case User.get_by_nickname(nickname) do
+      %User{} = user ->
+        MFA.disable(user)
+        json(conn, nickname)
+
+      _ ->
+        {:error, :not_found}
+    end
+  end
+
   @doc "Show a given user's credentials"
   def show_user_credentials(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do
     with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do
diff --git a/lib/pleroma/web/auth/pleroma_authenticator.ex b/lib/pleroma/web/auth/pleroma_authenticator.ex
index cb09664ce..a8f554aa3 100644
--- a/lib/pleroma/web/auth/pleroma_authenticator.ex
+++ b/lib/pleroma/web/auth/pleroma_authenticator.ex
@@ -19,8 +19,8 @@ defmodule Pleroma.Web.Auth.PleromaAuthenticator do
          {_, true} <- {:checkpw, AuthenticationPlug.checkpw(password, user.password_hash)} do
       {:ok, user}
     else
-      error ->
-        {:error, error}
+      {:error, _reason} = error -> error
+      error -> {:error, error}
     end
   end
 
diff --git a/lib/pleroma/web/auth/totp_authenticator.ex b/lib/pleroma/web/auth/totp_authenticator.ex
new file mode 100644
index 000000000..98aca9a51
--- /dev/null
+++ b/lib/pleroma/web/auth/totp_authenticator.ex
@@ -0,0 +1,45 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.Auth.TOTPAuthenticator do
+  alias Comeonin.Pbkdf2
+  alias Pleroma.MFA
+  alias Pleroma.MFA.TOTP
+  alias Pleroma.User
+
+  @doc "Verify code or check backup code."
+  @spec verify(String.t(), User.t()) ::
+          {:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token}
+  def verify(
+        token,
+        %User{
+          multi_factor_authentication_settings:
+            %{enabled: true, totp: %{secret: secret, confirmed: true}} = _
+        } = _user
+      )
+      when is_binary(token) and byte_size(token) > 0 do
+    TOTP.validate_token(secret, token)
+  end
+
+  def verify(_, _), do: {:error, :invalid_token}
+
+  @spec verify_recovery_code(User.t(), String.t()) ::
+          {:ok, :pass} | {:error, :invalid_token}
+  def verify_recovery_code(
+        %User{multi_factor_authentication_settings: %{enabled: true, backup_codes: codes}} = user,
+        code
+      )
+      when is_list(codes) and is_binary(code) do
+    hash_code = Enum.find(codes, fn hash -> Pbkdf2.checkpw(code, hash) end)
+
+    if hash_code do
+      MFA.invalidate_backup_code(user, hash_code)
+      {:ok, :pass}
+    else
+      {:error, :invalid_token}
+    end
+  end
+
+  def verify_recovery_code(_, _), do: {:error, :invalid_token}
+end
diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex
index 6540fa5d1..793f2e7f8 100644
--- a/lib/pleroma/web/common_api/utils.ex
+++ b/lib/pleroma/web/common_api/utils.ex
@@ -402,6 +402,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do
     end
   end
 
+  @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
   def confirm_current_password(user, password) do
     with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
          true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do
diff --git a/lib/pleroma/web/oauth/mfa_controller.ex b/lib/pleroma/web/oauth/mfa_controller.ex
new file mode 100644
index 000000000..e52cccd85
--- /dev/null
+++ b/lib/pleroma/web/oauth/mfa_controller.ex
@@ -0,0 +1,97 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.OAuth.MFAController do
+  @moduledoc """
+  The model represents api to use Multi Factor authentications.
+  """
+
+  use Pleroma.Web, :controller
+
+  alias Pleroma.MFA
+  alias Pleroma.Web.Auth.TOTPAuthenticator
+  alias Pleroma.Web.OAuth.MFAView, as: View
+  alias Pleroma.Web.OAuth.OAuthController
+  alias Pleroma.Web.OAuth.Token
+
+  plug(:fetch_session when action in [:show, :verify])
+  plug(:fetch_flash when action in [:show, :verify])
+
+  @doc """
+  Display form to input mfa code or recovery code.
+  """
+  def show(conn, %{"mfa_token" => mfa_token} = params) do
+    template = Map.get(params, "challenge_type", "totp")
+
+    conn
+    |> put_view(View)
+    |> render("#{template}.html", %{
+      mfa_token: mfa_token,
+      redirect_uri: params["redirect_uri"],
+      state: params["state"]
+    })
+  end
+
+  @doc """
+  Verification code and continue authorization.
+  """
+  def verify(conn, %{"mfa" => %{"mfa_token" => mfa_token} = mfa_params} = _) do
+    with {:ok, %{user: user, authorization: auth}} <- MFA.Token.validate(mfa_token),
+         {:ok, _} <- validates_challenge(user, mfa_params) do
+      conn
+      |> OAuthController.after_create_authorization(auth, %{
+        "authorization" => %{
+          "redirect_uri" => mfa_params["redirect_uri"],
+          "state" => mfa_params["state"]
+        }
+      })
+    else
+      _ ->
+        conn
+        |> put_flash(:error, "Two-factor authentication failed.")
+        |> put_status(:unauthorized)
+        |> show(mfa_params)
+    end
+  end
+
+  @doc """
+  Verification second step of MFA (or recovery) and returns access token.
+
+  ## Endpoint
+  POST /oauth/mfa/challenge
+
+  params:
+  `client_id`
+  `client_secret`
+  `mfa_token` - access token to check second step of mfa
+  `challenge_type` - 'totp' or 'recovery'
+  `code`
+
+  """
+  def challenge(conn, %{"mfa_token" => mfa_token} = params) do
+    with {:ok, app} <- Token.Utils.fetch_app(conn),
+         {:ok, %{user: user, authorization: auth}} <- MFA.Token.validate(mfa_token),
+         {:ok, _} <- validates_challenge(user, params),
+         {:ok, token} <- Token.exchange_token(app, auth) do
+      json(conn, Token.Response.build(user, token))
+    else
+      _error ->
+        conn
+        |> put_status(400)
+        |> json(%{error: "Invalid code"})
+    end
+  end
+
+  # Verify TOTP Code
+  defp validates_challenge(user, %{"challenge_type" => "totp", "code" => code} = _) do
+    TOTPAuthenticator.verify(code, user)
+  end
+
+  # Verify Recovery Code
+  defp validates_challenge(user, %{"challenge_type" => "recovery", "code" => code} = _) do
+    TOTPAuthenticator.verify_recovery_code(user, code)
+  end
+
+  defp validates_challenge(_, _), do: {:error, :unsupported_challenge_type}
+end
diff --git a/lib/pleroma/web/oauth/mfa_view.ex b/lib/pleroma/web/oauth/mfa_view.ex
new file mode 100644
index 000000000..e88e7066b
--- /dev/null
+++ b/lib/pleroma/web/oauth/mfa_view.ex
@@ -0,0 +1,8 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.OAuth.MFAView do
+  use Pleroma.Web, :view
+  import Phoenix.HTML.Form
+end
diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex
index 685269877..7c804233c 100644
--- a/lib/pleroma/web/oauth/oauth_controller.ex
+++ b/lib/pleroma/web/oauth/oauth_controller.ex
@@ -6,6 +6,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
   use Pleroma.Web, :controller
 
   alias Pleroma.Helpers.UriHelper
+  alias Pleroma.MFA
   alias Pleroma.Plugs.RateLimiter
   alias Pleroma.Registration
   alias Pleroma.Repo
@@ -14,6 +15,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
   alias Pleroma.Web.ControllerHelper
   alias Pleroma.Web.OAuth.App
   alias Pleroma.Web.OAuth.Authorization
+  alias Pleroma.Web.OAuth.MFAController
   alias Pleroma.Web.OAuth.Scopes
   alias Pleroma.Web.OAuth.Token
   alias Pleroma.Web.OAuth.Token.Strategy.RefreshToken
@@ -121,7 +123,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do
         %{"authorization" => _} = params,
         opts \\ []
       ) do
-    with {:ok, auth} <- do_create_authorization(conn, params, opts[:user]) do
+    with {:ok, auth, user} <- do_create_authorization(conn, params, opts[:user]),
+         {:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)} do
       after_create_authorization(conn, auth, params)
     else
       error ->
@@ -179,6 +182,22 @@ defmodule Pleroma.Web.OAuth.OAuthController do
     |> authorize(params)
   end
 
+  defp handle_create_authorization_error(
+         %Plug.Conn{} = conn,
+         {:mfa_required, user, auth, _},
+         params
+       ) do
+    {:ok, token} = MFA.Token.create_token(user, auth)
+
+    data = %{
+      "mfa_token" => token.token,
+      "redirect_uri" => params["authorization"]["redirect_uri"],
+      "state" => params["authorization"]["state"]
+    }
+
+    MFAController.show(conn, data)
+  end
+
   defp handle_create_authorization_error(
          %Plug.Conn{} = conn,
          {:account_status, :password_reset_pending},
@@ -231,7 +250,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do
 
       json(conn, Token.Response.build(user, token, response_attrs))
     else
-      _error -> render_invalid_credentials_error(conn)
+      error ->
+        handle_token_exchange_error(conn, error)
     end
   end
 
@@ -244,6 +264,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
          {:account_status, :active} <- {:account_status, User.account_status(user)},
          {:ok, scopes} <- validate_scopes(app, params),
          {:ok, auth} <- Authorization.create_authorization(app, user, scopes),
+         {:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)},
          {:ok, token} <- Token.exchange_token(app, auth) do
       json(conn, Token.Response.build(user, token))
     else
@@ -270,13 +291,20 @@ defmodule Pleroma.Web.OAuth.OAuthController do
          {:ok, token} <- Token.exchange_token(app, auth) do
       json(conn, Token.Response.build_for_client_credentials(token))
     else
-      _error -> render_invalid_credentials_error(conn)
+      _error ->
+        handle_token_exchange_error(conn, :invalid_credentails)
     end
   end
 
   # Bad request
   def token_exchange(%Plug.Conn{} = conn, params), do: bad_request(conn, params)
 
+  defp handle_token_exchange_error(%Plug.Conn{} = conn, {:mfa_required, user, auth, _}) do
+    conn
+    |> put_status(:forbidden)
+    |> json(build_and_response_mfa_token(user, auth))
+  end
+
   defp handle_token_exchange_error(%Plug.Conn{} = conn, {:account_status, :deactivated}) do
     render_error(
       conn,
@@ -434,7 +462,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do
   def register(%Plug.Conn{} = conn, %{"authorization" => _, "op" => "connect"} = params) do
     with registration_id when not is_nil(registration_id) <- get_session_registration_id(conn),
          %Registration{} = registration <- Repo.get(Registration, registration_id),
-         {_, {:ok, auth}} <- {:create_authorization, do_create_authorization(conn, params)},
+         {_, {:ok, auth, _user}} <-
+           {:create_authorization, do_create_authorization(conn, params)},
          %User{} = user <- Repo.preload(auth, :user).user,
          {:ok, _updated_registration} <- Registration.bind_to_user(registration, user) do
       conn
@@ -500,8 +529,9 @@ defmodule Pleroma.Web.OAuth.OAuthController do
          %App{} = app <- Repo.get_by(App, client_id: client_id),
          true <- redirect_uri in String.split(app.redirect_uris),
          {:ok, scopes} <- validate_scopes(app, auth_attrs),
-         {:account_status, :active} <- {:account_status, User.account_status(user)} do
-      Authorization.create_authorization(app, user, scopes)
+         {:account_status, :active} <- {:account_status, User.account_status(user)},
+         {:ok, auth} <- Authorization.create_authorization(app, user, scopes) do
+      {:ok, auth, user}
     end
   end
 
@@ -515,6 +545,12 @@ defmodule Pleroma.Web.OAuth.OAuthController do
   defp put_session_registration_id(%Plug.Conn{} = conn, registration_id),
     do: put_session(conn, :registration_id, registration_id)
 
+  defp build_and_response_mfa_token(user, auth) do
+    with {:ok, token} <- MFA.Token.create_token(user, auth) do
+      Token.Response.build_for_mfa_token(user, token)
+    end
+  end
+
   @spec validate_scopes(App.t(), map()) ::
           {:ok, list()} | {:error, :missing_scopes | :unsupported_scopes}
   defp validate_scopes(%App{} = app, params) do
diff --git a/lib/pleroma/web/oauth/token/clean_worker.ex b/lib/pleroma/web/oauth/token/clean_worker.ex
new file mode 100644
index 000000000..2c3bb9ded
--- /dev/null
+++ b/lib/pleroma/web/oauth/token/clean_worker.ex
@@ -0,0 +1,38 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.OAuth.Token.CleanWorker do
+  @moduledoc """
+  The module represents functions to clean an expired OAuth and MFA tokens.
+  """
+  use GenServer
+
+  @ten_seconds 10_000
+  @one_day 86_400_000
+
+  alias Pleroma.MFA
+  alias Pleroma.Web.OAuth
+  alias Pleroma.Workers.BackgroundWorker
+
+  def start_link(_), do: GenServer.start_link(__MODULE__, %{})
+
+  def init(_) do
+    Process.send_after(self(), :perform, @ten_seconds)
+    {:ok, nil}
+  end
+
+  @doc false
+  def handle_info(:perform, state) do
+    BackgroundWorker.enqueue("clean_expired_tokens", %{})
+    interval = Pleroma.Config.get([:oauth2, :clean_expired_tokens_interval], @one_day)
+
+    Process.send_after(self(), :perform, interval)
+    {:noreply, state}
+  end
+
+  def perform(:clean) do
+    OAuth.Token.delete_expired_tokens()
+    MFA.Token.delete_expired_tokens()
+  end
+end
diff --git a/lib/pleroma/web/oauth/token/response.ex b/lib/pleroma/web/oauth/token/response.ex
index 6f4713dee..0e72c31e9 100644
--- a/lib/pleroma/web/oauth/token/response.ex
+++ b/lib/pleroma/web/oauth/token/response.ex
@@ -5,6 +5,7 @@
 defmodule Pleroma.Web.OAuth.Token.Response do
   @moduledoc false
 
+  alias Pleroma.MFA
   alias Pleroma.User
   alias Pleroma.Web.OAuth.Token.Utils
 
@@ -32,5 +33,13 @@ defmodule Pleroma.Web.OAuth.Token.Response do
     }
   end
 
+  def build_for_mfa_token(user, mfa_token) do
+    %{
+      error: "mfa_required",
+      mfa_token: mfa_token.token,
+      supported_challenge_types: MFA.supported_methods(user)
+    }
+  end
+
   defp expires_in, do: Pleroma.Config.get([:oauth2, :token_expires_in], 600)
 end
diff --git a/lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex b/lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex
new file mode 100644
index 000000000..eb9989cdf
--- /dev/null
+++ b/lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex
@@ -0,0 +1,133 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.PleromaAPI.TwoFactorAuthenticationController do
+  @moduledoc "The module represents actions to manage MFA"
+  use Pleroma.Web, :controller
+
+  import Pleroma.Web.ControllerHelper, only: [json_response: 3]
+
+  alias Pleroma.MFA
+  alias Pleroma.MFA.TOTP
+  alias Pleroma.Plugs.OAuthScopesPlug
+  alias Pleroma.Web.CommonAPI.Utils
+
+  plug(OAuthScopesPlug, %{scopes: ["read:security"]} when action in [:settings])
+
+  plug(
+    OAuthScopesPlug,
+    %{scopes: ["write:security"]} when action in [:setup, :confirm, :disable, :backup_codes]
+  )
+
+  @doc """
+  Gets user multi factor authentication settings
+
+  ## Endpoint
+  GET /api/pleroma/accounts/mfa
+
+  """
+  def settings(%{assigns: %{user: user}} = conn, _params) do
+    json(conn, %{settings: MFA.mfa_settings(user)})
+  end
+
+  @doc """
+  Prepare setup mfa method
+
+  ## Endpoint
+  GET /api/pleroma/accounts/mfa/setup/[:method]
+
+  """
+  def setup(%{assigns: %{user: user}} = conn, %{"method" => "totp"} = _params) do
+    with {:ok, user} <- MFA.setup_totp(user),
+         %{secret: secret} = _ <- user.multi_factor_authentication_settings.totp do
+      provisioning_uri = TOTP.provisioning_uri(secret, "#{user.email}")
+
+      json(conn, %{provisioning_uri: provisioning_uri, key: secret})
+    else
+      {:error, message} ->
+        json_response(conn, :unprocessable_entity, %{error: message})
+    end
+  end
+
+  def setup(conn, _params) do
+    json_response(conn, :bad_request, %{error: "undefined method"})
+  end
+
+  @doc """
+  Confirms setup and enable mfa method
+
+  ## Endpoint
+  POST /api/pleroma/accounts/mfa/confirm/:method
+
+  - params:
+  `code` - confirmation code
+  `password` - current password
+  """
+  def confirm(
+        %{assigns: %{user: user}} = conn,
+        %{"method" => "totp", "password" => _, "code" => _} = params
+      ) do
+    with {:ok, _user} <- Utils.confirm_current_password(user, params["password"]),
+         {:ok, _user} <- MFA.confirm_totp(user, params) do
+      json(conn, %{})
+    else
+      {:error, message} ->
+        json_response(conn, :unprocessable_entity, %{error: message})
+    end
+  end
+
+  def confirm(conn, _) do
+    json_response(conn, :bad_request, %{error: "undefined mfa method"})
+  end
+
+  @doc """
+  Disable mfa method and disable mfa if need.
+  """
+  def disable(%{assigns: %{user: user}} = conn, %{"method" => "totp"} = params) do
+    with {:ok, user} <- Utils.confirm_current_password(user, params["password"]),
+         {:ok, _user} <- MFA.disable_totp(user) do
+      json(conn, %{})
+    else
+      {:error, message} ->
+        json_response(conn, :unprocessable_entity, %{error: message})
+    end
+  end
+
+  def disable(%{assigns: %{user: user}} = conn, %{"method" => "mfa"} = params) do
+    with {:ok, user} <- Utils.confirm_current_password(user, params["password"]),
+         {:ok, _user} <- MFA.disable(user) do
+      json(conn, %{})
+    else
+      {:error, message} ->
+        json_response(conn, :unprocessable_entity, %{error: message})
+    end
+  end
+
+  def disable(conn, _) do
+    json_response(conn, :bad_request, %{error: "undefined mfa method"})
+  end
+
+  @doc """
+  Generates backup codes.
+
+  ## Endpoint
+  GET /api/pleroma/accounts/mfa/backup_codes
+
+  ## Response
+  ### Success
+  `{codes: [codes]}`
+
+  ### Error
+  `{error: [error_message]}`
+
+  """
+  def backup_codes(%{assigns: %{user: user}} = conn, _params) do
+    with {:ok, codes} <- MFA.generate_backup_codes(user) do
+      json(conn, %{codes: codes})
+    else
+      {:error, message} ->
+        json_response(conn, :unprocessable_entity, %{error: message})
+    end
+  end
+end
diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex
index 281516bb8..7a171f9fb 100644
--- a/lib/pleroma/web/router.ex
+++ b/lib/pleroma/web/router.ex
@@ -132,6 +132,7 @@ defmodule Pleroma.Web.Router do
     post("/users/follow", AdminAPIController, :user_follow)
     post("/users/unfollow", AdminAPIController, :user_unfollow)
 
+    put("/users/disable_mfa", AdminAPIController, :disable_mfa)
     delete("/users", AdminAPIController, :user_delete)
     post("/users", AdminAPIController, :users_create)
     patch("/users/:nickname/toggle_activation", AdminAPIController, :user_toggle_activation)
@@ -258,6 +259,16 @@ defmodule Pleroma.Web.Router do
     post("/follow_import", UtilController, :follow_import)
   end
 
+  scope "/api/pleroma", Pleroma.Web.PleromaAPI do
+    pipe_through(:authenticated_api)
+
+    get("/accounts/mfa", TwoFactorAuthenticationController, :settings)
+    get("/accounts/mfa/backup_codes", TwoFactorAuthenticationController, :backup_codes)
+    get("/accounts/mfa/setup/:method", TwoFactorAuthenticationController, :setup)
+    post("/accounts/mfa/confirm/:method", TwoFactorAuthenticationController, :confirm)
+    delete("/accounts/mfa/:method", TwoFactorAuthenticationController, :disable)
+  end
+
   scope "/oauth", Pleroma.Web.OAuth do
     scope [] do
       pipe_through(:oauth)
@@ -269,6 +280,10 @@ defmodule Pleroma.Web.Router do
     post("/revoke", OAuthController, :token_revoke)
     get("/registration_details", OAuthController, :registration_details)
 
+    post("/mfa/challenge", MFAController, :challenge)
+    post("/mfa/verify", MFAController, :verify, as: :mfa_verify)
+    get("/mfa", MFAController, :show)
+
     scope [] do
       pipe_through(:browser)
 
diff --git a/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex b/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex
new file mode 100644
index 000000000..750f65386
--- /dev/null
+++ b/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex
@@ -0,0 +1,24 @@
+<%= if get_flash(@conn, :info) do %>
+<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
+<% end %>
+<%= if get_flash(@conn, :error) do %>
+<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
+<% end %>
+
+<h2>Two-factor recovery</h2>
+
+<%= form_for @conn, mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %>
+<div class="input">
+  <%= label f, :code, "Recovery code" %>
+  <%= text_input f, :code %>
+  <%= hidden_input f, :mfa_token, value: @mfa_token %>
+  <%= hidden_input f, :state, value: @state %>
+  <%= hidden_input f, :redirect_uri, value: @redirect_uri %>
+  <%= hidden_input f, :challenge_type, value: "recovery" %>
+</div>
+
+<%= submit "Verify" %>
+<% end %>
+<a href="<%= mfa_path(@conn, :show, %{challenge_type: "totp", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>">
+  Enter a two-factor code
+</a>
diff --git a/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex b/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex
new file mode 100644
index 000000000..af6e546b0
--- /dev/null
+++ b/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex
@@ -0,0 +1,24 @@
+<%= if get_flash(@conn, :info) do %>
+<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
+<% end %>
+<%= if get_flash(@conn, :error) do %>
+<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
+<% end %>
+
+<h2>Two-factor authentication</h2>
+
+<%= form_for @conn, mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %>
+<div class="input">
+  <%= label f, :code, "Authentication code" %>
+  <%= text_input f, :code %>
+  <%= hidden_input f, :mfa_token, value: @mfa_token %>
+  <%= hidden_input f, :state, value: @state %>
+  <%= hidden_input f, :redirect_uri, value: @redirect_uri %>
+  <%= hidden_input f, :challenge_type, value: "totp" %>
+</div>
+
+<%= submit "Verify" %>
+<% end %>
+<a href="<%= mfa_path(@conn, :show, %{challenge_type: "recovery", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>">
+  Enter a two-factor recovery code
+</a>
diff --git a/lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex b/lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex
new file mode 100644
index 000000000..adc3a3e3d
--- /dev/null
+++ b/lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex
@@ -0,0 +1,13 @@
+<%= if @error do %>
+<h2><%= @error %></h2>
+<% end %>
+<h2>Two-factor authentication</h2>
+<p><%= @followee.nickname %></p>
+<img height="128" width="128" src="<%= avatar_url(@followee) %>">
+<%= form_for @conn, remote_follow_path(@conn, :do_follow), [as: "mfa"], fn f -> %>
+<%= text_input f, :code, placeholder: "Authentication code", required: true %>
+<br>
+<%= hidden_input f, :id, value: @followee.id %>
+<%= hidden_input f, :token, value: @mfa_token %>
+<%= submit "Authorize" %>
+<% end %>
diff --git a/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex b/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex
index 89da760da..521dc9322 100644
--- a/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex
+++ b/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex
@@ -8,10 +8,12 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowController do
   require Logger
 
   alias Pleroma.Activity
+  alias Pleroma.MFA
   alias Pleroma.Object.Fetcher
   alias Pleroma.Plugs.OAuthScopesPlug
   alias Pleroma.User
   alias Pleroma.Web.Auth.Authenticator
+  alias Pleroma.Web.Auth.TOTPAuthenticator
   alias Pleroma.Web.CommonAPI
 
   @status_types ["Article", "Event", "Note", "Video", "Page", "Question"]
@@ -68,6 +70,8 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowController do
 
   # POST  /ostatus_subscribe
   #
+  # adds a remote account in followers if user already is signed in.
+  #
   def do_follow(%{assigns: %{user: %User{} = user}} = conn, %{"user" => %{"id" => id}}) do
     with {:fetch_user, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)},
          {:ok, _, _, _} <- CommonAPI.follow(user, followee) do
@@ -78,9 +82,33 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowController do
     end
   end
 
+  # POST  /ostatus_subscribe
+  #
+  # step 1.
+  # checks login\password and displays step 2 form of MFA if need.
+  #
   def do_follow(conn, %{"authorization" => %{"name" => _, "password" => _, "id" => id}}) do
-    with {:fetch_user, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)},
+    with {_, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)},
          {_, {:ok, user}, _} <- {:auth, Authenticator.get_user(conn), followee},
+         {_, _, _, false} <- {:mfa_required, followee, user, MFA.require?(user)},
+         {:ok, _, _, _} <- CommonAPI.follow(user, followee) do
+      redirect(conn, to: "/users/#{followee.id}")
+    else
+      error ->
+        handle_follow_error(conn, error)
+    end
+  end
+
+  # POST  /ostatus_subscribe
+  #
+  # step 2
+  # checks TOTP code. otherwise displays form with errors
+  #
+  def do_follow(conn, %{"mfa" => %{"code" => code, "token" => token, "id" => id}}) do
+    with {_, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)},
+         {_, _, {:ok, %{user: user}}} <- {:mfa_token, followee, MFA.Token.validate(token)},
+         {_, _, _, {:ok, _}} <-
+           {:verify_mfa_code, followee, token, TOTPAuthenticator.verify(code, user)},
          {:ok, _, _, _} <- CommonAPI.follow(user, followee) do
       redirect(conn, to: "/users/#{followee.id}")
     else
@@ -94,6 +122,23 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowController do
     render(conn, "followed.html", %{error: "Insufficient permissions: follow | write:follows."})
   end
 
+  defp handle_follow_error(conn, {:mfa_token, followee, _} = _) do
+    render(conn, "follow_login.html", %{error: "Wrong username or password", followee: followee})
+  end
+
+  defp handle_follow_error(conn, {:verify_mfa_code, followee, token, _} = _) do
+    render(conn, "follow_mfa.html", %{
+      error: "Wrong authentication code",
+      followee: followee,
+      mfa_token: token
+    })
+  end
+
+  defp handle_follow_error(conn, {:mfa_required, followee, user, _} = _) do
+    {:ok, %{token: token}} = MFA.Token.create_token(user)
+    render(conn, "follow_mfa.html", %{followee: followee, mfa_token: token, error: false})
+  end
+
   defp handle_follow_error(conn, {:auth, _, followee} = _) do
     render(conn, "follow_login.html", %{error: "Wrong username or password", followee: followee})
   end
diff --git a/mix.exs b/mix.exs
index beb05aab9..6d65e18d4 100644
--- a/mix.exs
+++ b/mix.exs
@@ -176,6 +176,7 @@ defmodule Pleroma.Mixfile do
       {:quack, "~> 0.1.1"},
       {:joken, "~> 2.0"},
       {:benchee, "~> 1.0"},
+      {:pot, "~> 0.10.2"},
       {:esshd, "~> 0.1.0", runtime: Application.get_env(:esshd, :enabled, false)},
       {:ex_const, "~> 0.2"},
       {:plug_static_index_html, "~> 1.0.0"},
diff --git a/mix.lock b/mix.lock
index 28287cf97..4792249d7 100644
--- a/mix.lock
+++ b/mix.lock
@@ -2,8 +2,7 @@
   "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm", "11b18c220bcc2eab63b5470c038ef10eb6783bcb1fcdb11aa4137defa5ac1bb8"},
   "auto_linker": {:git, "https://git.pleroma.social/pleroma/auto_linker.git", "95e8188490e97505c56636c1379ffdf036c1fdde", [ref: "95e8188490e97505c56636c1379ffdf036c1fdde"]},
   "base62": {:hex, :base62, "1.2.1", "4866763e08555a7b3917064e9eef9194c41667276c51b59de2bc42c6ea65f806", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm", "3b29948de2013d3f93aa898c884a9dff847e7aec75d9d6d8c1dc4c61c2716c42"},
-  "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"},
-  "bbcode": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/bbcode.git", "f2d267675e9a7e1ad1ea9beb4cc23382762b66c2", [ref: "v0.2.0"]},
+  "bbcode": {:hex, :bbcode, "0.1.1", "0023e2c7814119b2e620b7add67182e3f6019f92bfec9a22da7e99821aceba70", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5a981b98ac7d366a9b6bf40eac389aaf4d6e623c631e6b6f8a6b571efaafd338"},
   "bbcode_pleroma": {:hex, :bbcode_pleroma, "0.2.0", "d36f5bca6e2f62261c45be30fa9b92725c0655ad45c99025cb1c3e28e25803ef", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "19851074419a5fedb4ef49e1f01b30df504bb5dbb6d6adfc135238063bebd1c3"},
   "benchee": {:hex, :benchee, "1.0.1", "66b211f9bfd84bd97e6d1beaddf8fc2312aaabe192f776e8931cb0c16f53a521", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm", "3ad58ae787e9c7c94dd7ceda3b587ec2c64604563e049b2a0e8baafae832addb"},
   "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
@@ -19,38 +18,33 @@
   "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "04fd8c6a39edc6aaa9c26123009200fc61f92a3a94f3178c527b70b767c6e605"},
   "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm", "79f954a7021b302186a950a32869dbc185523d99d3e44ce430cd1f3289f41ed4"},
   "credo": {:hex, :credo, "1.1.5", "caec7a3cadd2e58609d7ee25b3931b129e739e070539ad1a0cd7efeeb47014f4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0bbd3222607ccaaac5c0340f7f525c627ae4d7aee6c8c8c108922620c5b6446"},
-  "crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"},
   "crypt": {:git, "https://github.com/msantos/crypt", "1f2b58927ab57e72910191a7ebaeff984382a1d3", [ref: "1f2b58927ab57e72910191a7ebaeff984382a1d3"]},
   "custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm", "8df019facc5ec9603e94f7270f1ac73ddf339f56ade76a721eaa57c1493ba463"},
   "db_connection": {:hex, :db_connection, "2.2.1", "caee17725495f5129cb7faebde001dc4406796f12a62b8949f4ac69315080566", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "2b02ece62d9f983fcd40954e443b7d9e6589664380e5546b2b9b523cd0fb59e1"},
   "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"},
   "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"},
   "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"},
-  "ecto": {:hex, :ecto, "3.4.0", "a7a83ab8359bf816ce729e5e65981ce25b9fc5adfc89c2ea3980f4fed0bfd7c1", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "5eed18252f5b5bbadec56a24112b531343507dbe046273133176b12190ce19cc"},
+  "ecto": {:hex, :ecto, "3.4.2", "6890af71025769bd27ef62b1ed1925cfe23f7f0460bcb3041da4b705215ff23e", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3959b8a83e086202a4bd86b4b5e6e71f9f1840813de14a57d502d3fc2ef7132"},
   "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"},
   "ecto_sql": {:hex, :ecto_sql, "3.3.4", "aa18af12eb875fbcda2f75e608b3bd534ebf020fc4f6448e4672fcdcbb081244", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5eccbdbf92e3c6f213007a82d5dbba4cd9bb659d1a21331f89f408e4c0efd7a8"},
   "esshd": {:hex, :esshd, "0.1.1", "d4dd4c46698093a40a56afecce8a46e246eb35463c457c246dacba2e056f31b5", [:mix], [], "hexpm", "d73e341e3009d390aa36387dc8862860bf9f874c94d9fd92ade2926376f49981"},
   "eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"},
-  "ex2ms": {:hex, :ex2ms, "1.5.0", "19e27f9212be9a96093fed8cdfbef0a2b56c21237196d26760f11dfcfae58e97", [:mix], [], "hexpm"},
   "ex_aws": {:hex, :ex_aws, "2.1.1", "1e4de2106cfbf4e837de41be41cd15813eabc722315e388f0d6bb3732cec47cd", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "1.6.3 or 1.6.5 or 1.7.1 or 1.8.6 or ~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8", [hex: :jsx, repo: "hexpm", optional: true]}, {:poison, ">= 1.2.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "06b6fde12b33bb6d65d5d3493e903ba5a56d57a72350c15285a4298338089e10"},
   "ex_aws_s3": {:hex, :ex_aws_s3, "2.0.2", "c0258bbdfea55de4f98f0b2f0ca61fe402cc696f573815134beb1866e778f47b", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0569f5b211b1a3b12b705fe2a9d0e237eb1360b9d76298028df2346cad13097a"},
   "ex_const": {:hex, :ex_const, "0.2.4", "d06e540c9d834865b012a17407761455efa71d0ce91e5831e86881b9c9d82448", [:mix], [], "hexpm", "96fd346610cc992b8f896ed26a98be82ac4efb065a0578f334a32d60a3ba9767"},
-  "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"},
+  "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f1155337ae17ff7a1255217b4c1ceefcd1860b7ceb1a1874031e7a861b052e39"},
   "ex_machina": {:hex, :ex_machina, "2.3.0", "92a5ad0a8b10ea6314b876a99c8c9e3f25f4dde71a2a835845b136b9adaf199a", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "b84f6af156264530b312a8ab98ac6088f6b77ae5fe2058305c81434aa01fbaf9"},
   "ex_syslogger": {:hex, :ex_syslogger, "1.5.2", "72b6aa2d47a236e999171f2e1ec18698740f40af0bd02c8c650bf5f1fd1bac79", [:mix], [{:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:syslog, "~> 1.1.0", [hex: :syslog, repo: "hexpm", optional: false]}], "hexpm", "ab9fab4136dbc62651ec6f16fa4842f10cf02ab4433fa3d0976c01be99398399"},
   "excoveralls": {:hex, :excoveralls, "0.12.2", "a513defac45c59e310ac42fcf2b8ae96f1f85746410f30b1ff2b710a4b6cd44b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "151c476331d49b45601ffc45f43cb3a8beb396b02a34e3777fea0ad34ae57d89"},
-  "fast_html": {:hex, :fast_html, "1.0.3", "2cc0d4b68496266a1530e0c852cafeaede0bd10cfdee26fda50dc696c203162f", [:make, :mix], [], "hexpm", "ab3d782b639d3c4655fbaec0f9d032c91f8cab8dd791ac7469c2381bc7c32f85"},
-  "fast_sanitize": {:hex, :fast_sanitize, "0.1.7", "2a7cd8734c88a2de6de55022104f8a3b87f1fdbe8bbf131d9049764b53d50d0d", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f39fe8ea08fbac17487c30bf09b7d9f3e12472e51fb07a88ffeb8fd17da8ab67"},
+  "fast_html": {:hex, :fast_html, "1.0.1", "5bc7df4dc4607ec2c314c16414e4111d79a209956c4f5df96602d194c61197f9", [:make, :mix], [], "hexpm", "18e627dd62051a375ef94b197f41e8027c3e8eef0180ab8f81e0543b3dc6900a"},
+  "fast_sanitize": {:hex, :fast_sanitize, "0.1.6", "60a5ae96879956dea409a91a77f5dd2994c24cc10f80eefd8f9892ee4c0c7b25", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b73f50f0cb522dd0331ea8e8c90b408de42c50f37641219d6364f0e3e7efd22c"},
   "flake_id": {:hex, :flake_id, "0.1.0", "7716b086d2e405d09b647121a166498a0d93d1a623bead243e1f74216079ccb3", [:mix], [{:base62, "~> 1.2", [hex: :base62, repo: "hexpm", optional: false]}, {:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "31fc8090fde1acd267c07c36ea7365b8604055f897d3a53dd967658c691bd827"},
-  "floki": {:hex, :floki, "0.25.0", "b1c9ddf5f32a3a90b43b76f3386ca054325dc2478af020e87b5111c19f2284ac", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "631f4e627c46d5ecd347df5a2accdaf0621c77c3693c5b75a8ad58e84c61f242"},
+  "floki": {:hex, :floki, "0.26.0", "4df88977e2e357c6720e1b650f613444bfb48c5acfc6a0c646ab007d08ad13bf", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "e7b66ce7feef5518a9cd9fc7b52dd62a64028bd9cb6d6ad282a0f0fc90a4ae52"},
   "gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"},
-  "gen_stage": {:hex, :gen_stage, "0.14.3", "d0c66f1c87faa301c1a85a809a3ee9097a4264b2edf7644bf5c123237ef732bf", [:mix], [], "hexpm"},
-  "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"},
   "gettext": {:hex, :gettext, "0.17.4", "f13088e1ec10ce01665cf25f5ff779e7df3f2dc71b37084976cf89d1aa124d5c", [:mix], [], "hexpm", "3c75b5ea8288e2ee7ea503ff9e30dfe4d07ad3c054576a6e60040e79a801e14d"},
   "gun": {:git, "https://github.com/ninenines/gun.git", "e1a69b36b180a574c0ac314ced9613fdd52312cc", [ref: "e1a69b36b180a574c0ac314ced9613fdd52312cc"]},
   "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"},
   "html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"},
-  "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.3.0", "f005ad692b717691203f940c686208aa3d8ffd9dd4bb3699240096a51fa9564e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"},
   "http_signatures": {:git, "https://git.pleroma.social/pleroma/http_signatures.git", "293d77bb6f4a67ac8bde1428735c3b42f22cbb30", [ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"]},
   "httpoison": {:hex, :httpoison, "1.6.2", "ace7c8d3a361cebccbed19c283c349b3d26991eff73a1eaaa8abae2e3c8089b6", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "aa2c74bd271af34239a3948779612f87df2422c2fdcfdbcec28d9c105f0773fe"},
   "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"},
@@ -59,37 +53,34 @@
   "joken": {:hex, :joken, "2.2.0", "2daa1b12be05184aff7b5ace1d43ca1f81345962285fff3f88db74927c954d3a", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "b4f92e30388206f869dd25d1af628a1d99d7586e5cf0672f64d4df84c4d2f5e9"},
   "jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm", "3c7ddc8a9394b92891db7c2771da94bf819834a1a4c92e30857b7d582e2f8257"},
   "jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"},
-  "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"},
   "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"},
   "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"},
   "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"},
   "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
   "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"},
   "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
-  "mochiweb": {:hex, :mochiweb, "2.18.0", "eb55f1db3e6e960fac4e6db4e2db9ec3602cc9f30b86cd1481d56545c3145d2e", [:rebar3], [], "hexpm"},
   "mock": {:hex, :mock, "0.3.4", "c5862eb3b8c64237f45f586cf00c9d892ba07bb48305a43319d428ce3c2897dd", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "e6d886252f1a41f4ba06ecf2b4c8d38760b34b1c08a11c28f7397b2e03995964"},
   "mogrify": {:hex, :mogrify, "0.6.1", "de1b527514f2d95a7bbe9642eb556061afb337e220cf97adbf3a4e6438ed70af", [:mix], [], "hexpm", "3bc928d817974fa10cc11e6c89b9a9361e37e96dbbf3d868c41094ec05745dcd"},
   "mox": {:hex, :mox, "0.5.1", "f86bb36026aac1e6f924a4b6d024b05e9adbed5c63e8daa069bd66fb3292165b", [:mix], [], "hexpm", "052346cf322311c49a0f22789f3698eea030eec09b8c47367f0686ef2634ae14"},
-  "myhtmlex": {:git, "https://git.pleroma.social/pleroma/myhtmlex.git", "ad0097e2f61d4953bfef20fb6abddf23b87111e6", [ref: "ad0097e2f61d4953bfef20fb6abddf23b87111e6", submodules: true]},
   "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"},
-  "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]},
   "oban": {:hex, :oban, "1.2.0", "7cca94d341be43d220571e28f69131c4afc21095b25257397f50973d3fc59b07", [:mix], [{:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ba5f8b3f7d76967b3e23cf8014f6a13e4ccb33431e4808f036709a7f822362ee"},
   "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "b862ebd78de0df95875cf46feb6e9607130dc2a8", [ref: "b862ebd78de0df95875cf46feb6e9607130dc2a8"]},
   "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"},
   "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "0.12.4", "8dd29ed783f2e12195d7e0a4640effc0a7c37e6537da491f1db01839eee6d053", [:mix], [], "hexpm", "595d09db74cb093b1903381c9de423276a931a2480a46a1a5dc7f932a2a6375b"},
-  "phoenix": {:hex, :phoenix, "1.4.13", "67271ad69b51f3719354604f4a3f968f83aa61c19199343656c9caee057ff3b8", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ab765a0feddb81fc62e2116c827b5f068df85159c162bee760745276ad7ddc1b"},
+  "phoenix": {:hex, :phoenix, "1.4.12", "b86fa85a2ba336f5de068549de5ccceec356fd413264a9637e7733395d6cc4ea", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "58331ade6d77e1312a3d976f0fa41803b8f004b2b5f489193425bc46aea3ed30"},
   "phoenix_ecto": {:hex, :phoenix_ecto, "4.1.0", "a044d0756d0464c5a541b4a0bf4bcaf89bffcaf92468862408290682c73ae50d", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c5e666a341ff104d0399d8f0e4ff094559b2fde13a5985d4cb5023b2c2ac558b"},
   "phoenix_html": {:hex, :phoenix_html, "2.14.0", "d8c6bc28acc8e65f8ea0080ee05aa13d912c8758699283b8d3427b655aabe284", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b0bb30eda478a06dbfbe96728061a93833db3861a49ccb516f839ecb08493fbb"},
   "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm", "1f13f9f0f3e769a667a6b6828d29dec37497a082d195cc52dbef401a9b69bf38"},
   "phoenix_swoosh": {:hex, :phoenix_swoosh, "0.2.0", "a7e0b32077cd6d2323ae15198839b05d9caddfa20663fd85787479e81f89520e", [:mix], [{:phoenix, "~> 1.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:swoosh, "~> 0.1", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "ebf1bfa7b3c1c850c04929afe02e2e0d7ab135e0706332c865de03e761676b1f"},
-  "plug": {:hex, :plug, "1.9.0", "8d7c4e26962283ff9f8f3347bd73838e2413fbc38b7bb5467d5924f68f3a5a4a", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "9902eda2c52ada2a096434682e99a2493f5d06a94d6ac6bcfff9805f952350f1"},
-  "plug_cowboy": {:hex, :plug_cowboy, "2.1.2", "8b0addb5908c5238fac38e442e81b6fcd32788eaa03246b4d55d147c47c5805e", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "7d722581ce865a237e14da6d946f92704101740a256bd13ec91e63c0b122fc70"},
-  "plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"},
+  "plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "164baaeb382d19beee0ec484492aa82a9c8685770aee33b24ec727a0971b34d0"},
+  "plug_cowboy": {:hex, :plug_cowboy, "2.1.1", "a196e4f428d7f5d6dba5ded314cc55cd0fbddf1110af620f75c0190e77844b33", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "15a3c34ffaccef8a0b575b8d39ab1b9044586d7dab917292cdc44cf2737df7f2"},
+  "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm", "73c1682f0e414cfb5d9b95c8e8cd6ffcfdae699e3b05e1db744e58b7be857759"},
   "plug_static_index_html": {:hex, :plug_static_index_html, "1.0.0", "840123d4d3975585133485ea86af73cb2600afd7f2a976f9f5fd8b3808e636a0", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "79fd4fcf34d110605c26560cbae8f23c603ec4158c08298bd4360fdea90bb5cf"},
   "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"},
   "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
   "postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "4737ce62a31747b4c63c12b20c62307e51bb4fcd730ca0c32c280991e0606c90"},
-  "prometheus": {:hex, :prometheus, "4.5.0", "8f4a2246fe0beb50af0f77c5e0a5bb78fe575c34a9655d7f8bc743aad1c6bf76", [:mix, :rebar3], [], "hexpm", "679b5215480fff612b8351f45c839d995a07ce403e42ff02f1c6b20960d41a4e"},
+  "pot": {:hex, :pot, "0.10.2", "9895c83bcff8cd22d9f5bc79dfc88a188176b261b618ad70d93faf5c5ca36e67", [:rebar3], [], "hexpm", "ac589a8e296b7802681e93cd0a436faec117ea63e9916709c628df31e17e91e2"},
+  "prometheus": {:hex, :prometheus, "4.4.1", "1e96073b3ed7788053768fea779cbc896ddc3bdd9ba60687f2ad50b252ac87d6", [:mix, :rebar3], [], "hexpm", "d39f2ce1f3f29f3bf04f915aa3cf9c7cd4d2cee2f975e05f526e06cae9b7c902"},
   "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.3", "3dd4da1812b8e0dbee81ea58bb3b62ed7588f2eae0c9e97e434c46807ff82311", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "8d66289f77f913b37eda81fd287340c17e61a447549deb28efc254532b2bed82"},
   "prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm", "9fd13404a48437e044b288b41f76e64acd9735fb8b0e3809f494811dfa66d0fb"},
   "prometheus_phoenix": {:hex, :prometheus_phoenix, "1.3.0", "c4b527e0b3a9ef1af26bdcfbfad3998f37795b9185d475ca610fe4388fdd3bb5", [:mix], [{:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.3 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "c4d1404ac4e9d3d963da601db2a7d8ea31194f0017057fabf0cfb9bf5a6c8c75"},
diff --git a/priv/repo/migrations/20190506054542_add_multi_factor_authentication_settings_to_user.exs b/priv/repo/migrations/20190506054542_add_multi_factor_authentication_settings_to_user.exs
new file mode 100644
index 000000000..8b653c61f
--- /dev/null
+++ b/priv/repo/migrations/20190506054542_add_multi_factor_authentication_settings_to_user.exs
@@ -0,0 +1,9 @@
+defmodule Pleroma.Repo.Migrations.AddMultiFactorAuthenticationSettingsToUser do
+  use Ecto.Migration
+
+  def change do
+    alter table(:users) do
+      add(:multi_factor_authentication_settings, :map, default: %{})
+    end
+  end
+end
diff --git a/priv/repo/migrations/20190508193213_create_mfa_tokens.exs b/priv/repo/migrations/20190508193213_create_mfa_tokens.exs
new file mode 100644
index 000000000..da9f8fabe
--- /dev/null
+++ b/priv/repo/migrations/20190508193213_create_mfa_tokens.exs
@@ -0,0 +1,16 @@
+defmodule Pleroma.Repo.Migrations.CreateMfaTokens do
+  use Ecto.Migration
+
+  def change do
+    create table(:mfa_tokens) do
+      add(:user_id, references(:users, type: :uuid, on_delete: :delete_all))
+      add(:authorization_id, references(:oauth_authorizations, on_delete: :delete_all))
+      add(:token, :string)
+      add(:valid_until, :naive_datetime_usec)
+
+      timestamps()
+    end
+
+    create(unique_index(:mfa_tokens, :token))
+  end
+end
diff --git a/priv/static/adminfe/static/fonts/element-icons.535877f.woff b/priv/static/adminfe/static/fonts/element-icons.535877f.woff
deleted file mode 100644
index 02b9a2539e425a7a8c244faba92527602be76212..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001

literal 28200
zcmY(IQ*>obw1wkzY}@FV9ou%twr$%sPI!WjZQD-Aw(aEhfA7;>W6ZI?HTS9-^|Y(@
zDt85OaS%|De_=-r0{7o@E#?36|M>rhgo>Il2ngtpe=h4k3D<r#Tq>wAvi$RS|K+0p
zq|S#a)oE;N=<v@A00Dtk1pz^!{RKN;Zf@gg1_JV95d=gt3k0Mt=I6Tbj)keA2?&VM
zzgiH+f8s#D>LIoGXa4i@|K$Y#L=L?Lk!4});_=Vx{g-2afIyi18w6rwZ~R}~Ul0&5
zw*TZCR$*Xk=<#nZQ}}<ciT;Tgj2R@u&d}EMpGWs^J<|W`3<qLLKppIzT|hw0{>=$p
z3j%@wci;?(=aFM<U}9jfW0YV#YiQu&cn@Q^V_;wy0FnT2EN|vTst7+0K3*4KiAZb~
z903LrMFy?_^1ovY4RU&W$9wx0$6Ue!0}F_RS-Z`cj1CM842%p+&3a+M=5<uC@$=|C
z{G!@+{lP`xoxw%G6*ExCp+R0X5ac~_Hdh{7(p@o!Zl90mb(Mqr3?}j;{*J-JyScMs
z|K0o-)D)YNWCU*R=UkM*d&HDi;h)ei@3qU(uGhNmRqtK57hOn-&}9_g32R>WSW#Pr
zM-sk`m0uq8xYb`Cmsw(7J!k}vp6qi1VS~jP7&6A5mE-EG{5)pI7l~c<3JjAJf7Ao{
z%?06O$C!E2hN3FRmRCu5Ow%tiyBh2ns`-x@zc75e<ZXl%p3EWkGbCzLQcDa^)!Wnk
z*mX7h<zw}XZGEX+%O!kqPaZi6ndK~`S+f>`(i)8rv=+je8;kh-i@>exF|8Zoy0d%E
ze^yR-Rn9=!jEdV-)~sl5yJK;fvbNWAZT=0qvKdpinc}dSaI={~ycm_gm}Gd^0er~R
z)M9-DIXmj{IvSw8>#@8WklyP7dhek4qeA$TB>3Zo_|qu(V@mi_%=j`yUn2T(`yTQ;
zqHm6<RL-!aPPnDcQ07jU=FW)6PK3wKpjb{QSk5r%PB`h#kQz=H8qNqSP6R8?;G9lq
zoX&79PIxWO(C$uH?#@WhPDIc5AV_z}NcTVD@37<VAwE5mO0jzdp_nsBc&8M@m~{j$
z6Jw0njIsX!-aROp5~X+tOn^+Oma^thteHa%lV*s{kJ*_sV3_q0Lv*`bdB>1jJ91ll
zVp~T9dV8jNyJ~v-x_ZZaTgTx12W0$5X#9t){Ks&gcFBydQ8lk&_OJ3ir{DE4*RO$x
zAEV#D`xGh<%>8yX{Px|y4AcrzlvS)!*GlBewa-%DN&>&QaZ`s&q%5_vQjbc+EH$-K
z*`?3tfsOQ+56~*ljNeyZ-{0)jU)J+W*Qx@kC-zG!MBuUtn9Q>kDG@^I6k_nrv_eJ^
zr!eGUS$Sec8K>psVcB^KMj>irsAO_8bj;%w8dybgGtA7x529$sax)yv(+??V`*Jfp
z%zY0BXlQacg0d_npv<c&aD(V7!pJIennJRy6@VER6+dKFSt;R3Hd9#Ui4=xG8<lBf
zXW2-RNp@2t=9v`XK_3-D6gb&2Q93qL^t#a$^+6*QI}{mN7;#?nah5$#l?oKbjJsHm
zxfGW{8Q>(DUULP)9)=1cYE2euqQ_K9?BF>tg?x+Ykm43d!xh;gizD4>E3L9epi+%`
zorRj_F_Y3X_zqm8;Ac8yye*)KjEtAfl=ZQZHs3>2kw*h$p=Q5Krfd!#1JS9vnGU&7
zfF@M)DYt{^z(%TWmP7vArgG2-ds$sUA8RYfJsuSSWEnX*Av#u9sN1e`z6c^&K4Cge
zcG$Z9MfyPnU>b>f)?3)i>LTwTBM_0)kG%=yHoH7MVp|SD?8ESk)+n{SX%tt*Ke0(x
zPJEfe6<2d)(auCyWhU`aHdbPp)0JqocQMFBM1?3RR(48~gTs}4b#O7wL`B6uX-XDB
znuF7oX0?B@!bC=;C#gzSPNtc|m0@*wvD-vj1u6MbHdeNo)0K6#Z*l%03)oG$!otqk
z)?7X?<|=C|@5c7Xy*jpdbI=Z~rdnZb;&1CN{~kk-l?m9GZG;((l|2vG_}hp!DkmEq
zcww`KXo9s6Ma;>53FE}NP*2>E#R1jUW@($SDccde(cdUIdIh8xwzJftI8X=r3ftLg
z(H?LBr-bdSwHOcFfCM6T_F8NQh(KGBD+?{I19@PV$d!#2{{aIKLG+2Kiln~O5-YJw
z7B2F_c1`-A8n`5SWwxepa0R3gyE0r;J;(#Pid~tmX&!6=hs3Uo*Ypm$ff(Xf=4-|W
zUqDlFLW4DngFs-DIHAd!?Li+9Qi9NE&FP>3s3t*Zw&s4Y3oMZ!G+gsJNCZAg5Sp$9
z9?Sv7Bngez!Vc<ysgh63mtyrbmRw0AnH=PvotAP*R2gm*o`sf%NuRRX$-HBY++)dd
z@5#JFjiO^Ja@i@oQ;qUt3vxWEyaSED$5`doQ+X#Ejm8?}ywi9`8g0g$<?zyZXByqc
z?&UhtJ9`@g#^B_%Gdf2bqsF4;-ZMG}8&k$)<gzn6CmZv|CggasI{O>T#(v7JXLXJ@
z){p&>^Um%ZZtNVhmcz^GoNgQ*JD2Oo>FjHq9mA8;&g~p)TpP=ld(Z70YCIUzmdnoT
zoNBxpTbJVjbPhDWjPb~=13D*MAQRlPuR!kz7fc1WtlLpW-(^?8JbO;<h>4hVkq?4_
z+Ce^}E@}x#*;jCPC<`S*{8sH;qa(5_(66nhh$H|eU2+ru1zTZ$%Xa0_QrTx%u3ne6
z1aLV{c&<^Gsf1uTcLc6Mm)!(~Y&!y{vePq?@XRYBr`A(cQi%*ZQm5imZc>TND{`mC
zQzOz5?6XIF>!kv~e&tCnzJ1Br2aI~fG{E8?Nc{u<;fe`bD(!M^|ESc8`%<lcko6z@
z=Y;(O^Z%T*e*kDwcKx#XsdhY5_YZXbgNA?L^K<RHtyg;K8?H+Av*+(Wkg05ZM@#%L
zEbz#w?1C}SUP@*9xbj`oux_E(UN(|)!~fZAD&>~*cP6rd8;|6V2x@2uxge@=KyBC$
zi|`bR5K!rXMgz%-+SGYrU?UxdcEKn0MB{=ow_;1K8@Ik?DUS<o*T?b3d)~vrZ}Q1!
zs%78zu46m%o?}=q(67jF(c~TEJ>k+>#z`~?)SGAcL>qe+6j#(>P6M{$6J#k$uU#{R
z-s{lpIh;j{n>TN#5M7|;A&LN1S5Gb5Z@ugDY*&{Z*Za%xtn`OuARbR}-%5H<w~x!i
zBk*v_I5&JU4+^er+J^tif_*(+8@EN1Zsd|V`m>cw^6D#e_2aE~PSe-7MjKb4wJ!33
z(UO00wX^f|aZYJ;{}Bj8$PK4MAY^41k{Y=@QXe(UE}6SS=V6B{B+j9W3ZjN_70#Z8
ze2jW9VXfX0r9)+Z(b4nQx^_;mAkB;{k(_6jbV@{qX~iG>E99ftViOjF*0<}%b3h6d
zC0<R{ByBQ>8EgJC5_Dkaga%63kZv-zn>M`Ou=?caQ$DJnbbILNgE&<M-ulJRP04`H
zD;&;5JqbOE8cZ$<Ka7k+p2;SdK+3u!-V2yAMH>t0i--sRVb;I1yO|gu@g*36P2j+4
z$rd6RhpMY$mQ__g$Ig_Ja`Ja{6uWErwOlScZqYlvM(_P_qf)zCTaw)CYQE%s+LMfJ
zO(DqM#Jk1j^Keb=>NVQmtFrGoY7?~~*~lS_J>!&#19F28Wfa^A*0z0~`fAN#`t$O(
zy5#bxO@mI$t3XMB(*Hh|_>>5ttM0ut`nW@*>ho}!zRS2f%-)y?R=n(3%CY7b>2HW0
zCUP6(X*34R>aaC4FSNhlme{6B#*|YG*;4IPqOqi^{9uphXu)g*6Y$FZ#CSX5$hO04
zZU*T?ERi_mnCy)SKN=OGnQ>#f$!CTI1e2`d>hc19*rtTV5s|VX@nJl)Pv5uK&OE*C
z2}Rqb)wQTiw>;sRpVIZU*2EQKPBn@bUhDwoj(VmS);oLJRz%?2cgi;DITjQfPMYJP
z9^Xh!_U@qI91`~QR@CE9>JuHaWgcc7BV$9UY}Rc!mM{0O9OkJ6@Ggmq$)v@7#%jHJ
z^O&i$E|-7tWIgs-KJJGKiiiZ@%CY)9d0#iZn`OkffC>1oHm92#C+lz9xpG&nr#e2+
z-+I*%4~C>LsU*~z8lsnaf9QcXqOu8+iz1{_%JrR$L-Ho-L>)jGrVSTpzS!KnMUDu4
zLX6me!Ucs$j#b);7sfVojBBtp&o>Xk>vF8FizA+D<P@=9>6J5nX4ZS9IUFSSygS3m
zHi(kIix6ZDUj9hVGyQy<XN^S>CXIE;>-N_~qDhN_`+%O|_XfCP^MHPtppE@bzt*L~
z{_Q(lz)u=OkiCOy1~;HcrO%BO2{})w&mH<{FMDjjK%GjrLXRo;U<O?qq$am>FUpHT
z`_V1WfO(#mz+cgGdoqeSvoK#0&rT;eTjHl%skfy~wD8zaL3i^tU?zw6p>+kDOji7x
z8hy0SzG~Id#U6)C%6=={;CZP9d0tsTFF1M%@I<j>l};%S8x*-z0^({Jv?T@0We8%{
zt78~Q>;b}GCK$nn2BNpX#bnjM#p^EU?MWI@WrnsTFg19NRP0*^x_3_O@X&j`{uqC`
z{r%hbmk?^Aoo=Pj)(Y~1tHjQo&fWVP-bzhR;)kT0d*XmoFff}iy||DZgZx9HrtN@3
z+P-6O11*u%vcV@)xhQ+evUSc_zae=(_m0dd5WS$}w=>tqO_QiYW!<d?dJX7D6noAz
z6CIlm-0giFXfIl&aag{#f%21?A>mtYfEoC(B#Ti<;t7f~Vs`Xf`N(Q6xm^eE#1-Y3
zT`9C(n+;;oh&(htVZ9)uwhNb6b;(19DVsdvkma1^&tG6A&zB78x#Hk)K~rsGyN}!)
zx9wwK7$E1wK4Jkg#D5`ckkJc;c?2_q{eF}Fa6Abw?kkh%v}YPF*o^%OfTjr)2<So`
zJ=*)rwVFfN{u5eF=qy2U?`J0*fhazCgk`56ZeA1BqibpQNit2B&U|*H3_#j%v$BV>
z$vkbnEmR=&8&M&$jC0~!*Ym6b&#$|9B|Y!hvbKqReN7tp^0t3h?W}g^*O{|&PvOMg
zcTrz8tDh1(#@i^7%mnu~4w4M>HY}90`0p!7RHkNc1Qq%QYCC3{NQ{#s=%MxFPi3MS
zK2LI(i<d}cpJ02qoZa<9ipMwm$_674=)HOLif0q|*G0Q)zyOY2Gi+tmhAF~z`|HBI
zr;4nhf#cZpAz<o?1xmNj*6$h7+(XUESO^^sPvuYlG{I@840yLoQ6I7elh<0#6}@v>
z(`8yUH)YgFb&}h^?X6Bl@$9z#CE%CFDD1HyUwt53(s%%XTQk=PDj$I+<3m2j04g7V
zK1-lDff@BEtPFbqwk`Va&~NmDnKTb_t?sju3!#(DH0!!si*51vbd2e>-1O@VEYpJc
zl#{y);fp(%@o1u2l3xB{gdtZ$pr~zZ!{GMKB~bj&bl2>Pk=+Aw!_>-V29EVv?%XzY
z(?~;ZZl;NLyK5+Wy7rlErWAlBa?k>Ca+SQtPb_<VX;yF&bFOS9yKGgstkD3ePXAYH
z0BZIWZCIym!=<`P@0PzOlMUsSc=YB4sju$(w}aa|(;T}BZXx-4SNLx%L=MF{dZw@A
z;un?&cgD#TioeQ=zG2jkq@+P6O~b>iwQl46)CwSP%q-18b$FVh8t_zoQ>{liC%y|>
z>3YN1WMK@~ch4<my64p_r|y-y-mQH-CZ>(H`L`FId5=6X%fZHY)ok;8=}vY*C90)u
z#4~^%i>K8bV)&fgE6x)J&6Y0}hWEb}?10!ovua#D?;)*~g1Sena|R;34k7+ZKj_o^
zqny~-?P&K1!ajr|9pYgVhVn1?s{s9U@GIIe+O(p0c|h*iW_Ekc^?J2&i%p%b14^V`
zx8b9Gb=%QT`l%w%dAG`|r48S5@AvxP1^YG~zwbgg8|}NIDSG|3qpa=9Fh>iMmqQ_o
zZMMl$&wduessya*aOG8E*xi$R9_kNCbZR^4$&wRdHm-T<P#qdk*&$N#M{<X8sjCz;
zT)Qhb3HOW9U+E7^r>G)Q{`>8^=eVC^1tHbd_K~a&#uAI0o0B&j#&Q(-lfAuW{)0$J
z{*(Wj1Qz9hEjHWzJSAhBu?;uh>uJw>x2Lo9V}?i^iD#RfWwx&FAtnuy9kGMxM0WK!
zfozwL(_*s5+`Oh-2wQU~2JBM_=(}TD=Pi&2hN)K9!n*^M=^`?WhrW104QIP-=Pjq!
zs1?dpG09!Y#1I@R4hGh*$b((^=C0zKD|G%>%kB&;bWKBu9Y=6FYH$*Q3DECN1XEI_
z2~l+T#DHBi@HG5cah5C)tAvRg7|6=fz7wNL=p_CNebNlsr^$Q)9O-ErTL2c21%3=%
z<GN?~>~Yzh^L<@QvQuEWJOAZoiMs`StnunB{Qk$O6s5<(>5x|!PFXz_vK4s&@n<RE
zqBShZH(Qqw5btr8jTb5Lm3z#ijg$VX?vaP^mHZkVQsIu99S){UiQS^wsuu>&dQ3JX
ztm)8tC&?Mw?qv}ajGfqu1Vp36g2i{6K4q)EW>i#K{fQ~13R)gfCjNnv49Yj8so)k}
zF{!I9f~c7JV!5@mGS`QEg_#go7JAg%O06V>I#S-~@939vONBI64+ih*_qZlZBH(wa
zvD9w-iXeQh>dJ^!Hp>T6-F|dfe^9lTxY-dO0Z+#*W@!S&8|n^1Ub0ma6&<i}f+dcO
zr3ft-)m?GNN)DQ%%aJZ0v;l4W6HG2iJPi53{F^v|+&~5_fJ1SZ8BGcMZ^~U&C-rkR
zCjaY%TZ{27e)5Su)A5+}cH3qP*TxgZ=AZsZXAZXUXB~4?UxcIEq2g$lNK~8+-?=j{
zz{gh+{)fbvj2!?YqpG1Q*0u2PEb|@_?A`RS%}rDJ)%9{}A)K(0V^L`W?=JL!VftLC
zwEM@>{eXoPbPQDjVXp&vBq$nSso=nfEl8C1@v${QKYX1*X|(bh!x@idwn@x_4O>f)
zyFU7drfQZr4hD^3R$+%arp8ra<KGU)_b`U`BOkC!nct81RFL)5*}AG;fh!@HCQoP8
zsiJ%Pc3>XeOgpI=voJb&KZAxu;Jg!LZb(}BF>+H3<)2NQaWa-&3RTIggc1U@!%Ld+
zN!mDIq?0KE62X58Wedq1S{A7OXhxlvh6YKL1>vWu^)jImVH5KNqYMQvB`HEfiqMG2
z2I0mMT!M6(GBQM%j+BLX<Au<CfS+F_j;9e8qRT4$CHMpEm|b+$g4yXtNuODAZw=1Y
z7!Au)KaRs(9CaOyEYr*&qzI9bZR=?(7A{$De(%OhnDBZ@9%*vZOcq2aZRXJKEn;Ec
zF>P5;nh={SMLxzPJFA{7^5I!f(8vGzlC93d`1<`utY+nwnq?y)207lDC(quzEp0}@
zXJ+Bzk;5ATa+?U!(*kj41&U;nT%8gI0W}m-3QdF!CW(8W@nO6#hE9T5412^e_qP8q
zuD{(iJ==-Qi`0J%m3=}YOlq{Xu*M!zQ$kC2;{82s!akY1SJB^gm1CjX?%V38i-F@S
z<S;uH>L<lHST>Y&kJ~Q`-)%5q%!j%M*jH4ibKgzNI)6}I-USwsL=m_Eo*+Ruvw%*f
zAD<K+`Djq({bVer5Q2x%eYKILf!Ts#Xs<xJoL>gLC9jdAOVZ+USQtT@4Fg{jX>@Iq
zM0uM8==%J$1iqPUU1ioVJnGllmp@wQmR5#JN6sHi_AvdPO00X%=zPat)y5x{;2{$t
z9duj$wQ~LDxP_PL=U3#;k=zMB4L8&1T?IbGo&0?5t~PW&KZ<**>guLulwT<wKY9ai
zjbTH3z5Ku(mcR<MGiWAJFu|Z3qAb5V#e<Bkp&SBJZs$jzz*oUjAWJ0?oP!s)`!(@>
z2cd0DA+W8;Gx<bP$`OQ&#wlY)e<PtVSICeb2zV%W(X?z8!HF6_t=pJc&vt1aKY48q
zYWkr@uOY3=EsMKbBE2r#a3*TR<G64Skl3#|ue`pKY%+cyG<%)Z1&6?$np?;MHnu3g
zK^x{S-Jq+?)@`4Dv*q8PY$lY3|4OBbRZsrp@MgnA-Y#j_j5Q8}6dxdoKd%1XxDMn+
z+I+(42%o3VSccP~;$~n7%v^_G43=tk6yu8!MwY;f5L1=ii!5k8Ag}wCoE5>CRIr_z
zmL=^hD?{-eW*fjOdcs<73vPggQw#UHm0@GgzU~WY)WZH3fn!y;*yy-4o&MeBc(!+;
zqc+{0kB@0mQ8odV<&16ntF!M%lG5om1$qxgjt)9BB$YwCp5c$-vO-!#HE1qz)mCD3
zp<JUVrv7BZqQ9e_o~Sf`MnT*Ic|S?h2<_t7m&_}5Ni?y}>dnrwji%lJ_&iTVt9!R1
z;c?NTNdQ}{bGn0&5_uacNCQStRu+W5fTj*HSfEV{N5Nj{sk$~Tb(4$s<A@*m*>)FJ
zcPPF*ES6TK`a~#(9;jy@`GO#L)76ylI~awK0SYwOzwTu)4wgnTQ|C#1$2@UO#5kJZ
zH9u)@uU#C8Z{9YN<+sn`*x)D@;@P>cjFOT@!YJby$Ucld=r68&7Ux*qys4Lg^b2dV
zJ8$~Uo^-hP5%uwBr^}j*?{EQuvR*BN+G&%lb=DBInmJtRnWiK)`d&bGPacRRIGDup
zOgPW(19eG}Wm=McVrC`jcC(L<7@_lKV`u}lww==$z<g%p6!MR{uApYc&N0;x)*wnB
z`JiDZlELQQkGwjuK}*EoaHm^}@T_BGNKi*8p-&fB1(ZuJiyyP1y~w1&Y>>%;Hto|m
zOc>M%Gcc=YaMOfLa}M6qY1q1iZxZ!JU*q8drrP}9FLxIYEh`V%%{u%J%cJp;oOw1Z
z%VJ%=&3BgH$tyVL1S^>XY?xZiS+$321B<-(7mzUC_m>lKjK9s^7YBYG=ZZ~7P4QVT
zf6*U(HQ9g9<GJEwCp$9E(Ladquz{A6?(tyT%=C8JL0J~znd}YqOJ}fFxZ%`s(kq<G
z3Wr1iJpm<}0=pzuv*2c>b!CaZWa2(i#i;QP@JhtlJufrLGq2~#N5C?>x1wHx9P|J_
z50`d^P9ddnnTMUDDd-wgC$!gePjPK)O7xpH`n+YYb}@#+a!~TD@Uc7!Py4ZdTM=gc
z*Nvn}?G{TX`%ihK@o(0eU>PQY`-p-%k(tBoDQFs#nC9@KuWE6XS}}WjsnLl{h?E)u
zpCz?$jGSAJ8wtb$r3etJ5!c~S`IpUM$$ok(>ePzZNv6FcGRStOY+Xqrj}7-d%5RNo
zjLZjDuu=(WbQb}Bw~LVj%|%X>cAnUc*?t{`nvZQH0a=~;K(yTcI-+wI0m`Xe18Zxh
z$s>O9?LcrR$OV)vTF6jFaxlf<6bH%1-o!}Wmhsv%+qb<hyZ1<ur>jr>6jR6yb%cP2
z9j0)DzpY1cHMScsO+3q^a5zkN-mrY+OwcB`>T}atq0ASfYZdod&a^rRX-CT74I>Go
z;=nHl14`?yj+>xAFh6yvNPs@l5>GZ85BR$0h%Cb>`pyq@vF>hs-ZVeIuq7gnH`5~u
ze&|4g4-n><i|z3UW`OA=iY!!!Go(L8GuS@irThAyw^`$66K%l_$>3u<EkR;+OI)8C
z=CcrToao`4Pjz>iuOOh0AJ^)<UBsyBP@lUgxm#g?&9dg|{3*aJ7t`f3!9Z3-xg{R)
z>C&XNNX_DPPxAvntOwn21~;W^r?9P!qt%qz3%zAv>BA+NgAOpuh81?gt}nnhV;V-*
z%Kr`Hg>xFQ)PVm{%xo#>iWGq5T++~H!jNDKYLg<{i<Ae(9P>I4x@d(9-Ud=j1?mB9
zq0fybLmD}W!;XPaOMBN1#Om4JwQs7@Q~{iM^ca8nNP^XkAL?ZHI3G<;pX5n8_n+fu
zYMWc$aY#Ig{;|&z$vYYi_W|Ci7D1ww^jqv3927Hg@@Qc<t&_k^$00w?AIW7^sxJhv
zAzxmcQ@gOaFG=hQccqW_bs2CY6a3bi_POt)eZ;ozNY`9vuU*|nc!>|mP{zsx7hLOY
zo+5^^pg7n76HkJ}9*QyYQH`6RVfLCV;SRnm8?(-1{N@L);9S><#dNsrjcOj3j%wn$
z@%KPe$3Yasy<vtCd;W2mr#^GTA#3)I;r5MLoZ|GBlduhIPg+Iu@H>Wj{aJoLQ`m)y
zT%OvYm-06wu>0s&ha{x|zLz0>GaSy&Fl0PXdj|qq*PcUf)83-*Qcl+MKC+rbIIP{H
z0=~gkWh0w?s4Ma=wz`1Clnx27+r=^?{tf5Bk-{Jt7l*cklel{n<3_BfgfRSoq4V2S
z9(R;)xpjfGhK*h8d!g`;b>lqGcohGT4t@EP#S({aMjZE$r0yx8(rY1IF4k|(C8em_
zXsKXQ`wW7+@5mp%m^knyG(d=nGQ>pvhic3B;)2)<S)CH?_iMa=%C{^{-REs0prz&e
z10`=c1JtFTvFFNMU_QFXa+&jZ#(!aaUUtfG>cSRZf7QT*Dqokvu$+nVLXa<1JmSfM
zac@$*tg%_oo5ajpFfH)efc63PGBmtHz(M~<VOKubhy|h_u|0Jcs8qQkmw<sK8*5Lv
zh}m5jBobk(;cMFJWDX8uCf=bZX6yP%A6_AwE5p#g?Mn8O-LpYCV?%`|=6vLfT91l5
z01G#Oc{Fv;^2(`~+JT{}0-FUxD`G7Smj2-DN1@%NHx|fT^61sS=6`mJl8oU&d&NYP
zAVH0?9<ROT&-vc_;r*%wEbDTtSdB#KDQmFO_-!;ItcA&>C~lUE6q5d8MuSK6YqS$a
z=v*P4L~>;yrksG7j*jwvSLBS&c8(eA$c1M#g?)Uc?Sf?GCLt%!-I2J=mMrfhW~cG(
zPAZqZ<-_l_!)IVYFt|=Hg2$}<-6i4+y4~-)g!H0Za$rGkn5Whm-{1zrcQFA!djsd>
z3(SU~KAaCYk2S6oHTQ&s0lYWP<8e^viV&(42>VKGua{RMWcV9)M;%no2C9otZ9AX%
ztArWr!yO>XLul<4k{1mJ&SS3yvs5blIoK@vP~m~PBgFo%sU>hPuis*@H3RED%8qc2
z?|7fP5x=&LdRf#U&zq8Kid>D~KzJ@cQ8`hX`dZq7P@U}xOrX2OU{E+urqwet>~$4J
zbvAn3nu3>bHzR#aZyQw~1?z_|@%gkleq^vGglfz;^R#a-KBB`{h@82J47X%d;Vsf{
zUA_@zM?FyH?c`?0(N}(F#1%%wyz_fz(AMeGR{QPlcl>GYWuM))b)(JC$rR1E!ou^P
zOlst>YWK}D%k3j>Dk<AXQwOc-SA4>!iCroC#`O>F6NLa@HFSO7H2>f;VO7(LyX(^Y
zZ63iW{YtGl<FSl*<BABUn7~HRO8e>HBbQKXPBRZaU-I(Kl3ef*O#9l7GKq?H#Qa=Q
z5@+wM%5-}N4+{a;Rr{U#l0hNTZA}P9y8Z&4fIzK)0@-`lr}SaZfg4p!azL>36ZdzP
zZ_1VS{xcFCnaOH^zMa;`PoI5_Xh#Dqx->9ZRJHE!t#9v7+66ac4^FY#uaHL(PSz$X
z#L5e*a{Zt3mL+;_CDj#nXqGcfH$@g>XJR!N@ub5ka&%FG`+IvbPzU$`Y)3I(pWZv>
ztYtk1BGMzxunIEDBS{@0`6#grt&&1v$nIez^f{0kh@6zaIJpVMNuqG|ie^6=CxuYB
ztok2yP4F$ccII9nFhtcYA}#UmO^*VY2;P5<CiF#piJ(lPe*s`DELj5YNoEinET|7S
zT5nbC4>4ZhcJn0y!{BaBz{m+$h3G31H$Ht;(+V7aFgXPuwp|Y(JLiPRh*kvUFOx|0
zATAPBbz6`?LT&f5p^n~z>LY2+p;5^b=khxCBZB8UZAlaHJA$2(>j(;EIonADcS@W9
zGN1GWB_u?9WAYCs1G17!H%MwS&ZkTkZPMbi&o|BHsd~)5ZWgs4I4P4q%G&1W1gx9}
zR3ashye80}*_akVx8s-uJ<Wzwnf4sU5zhLR4^|`rI8K2qQ!WoFJUxA=2ZcIpoUO7Z
zO(lJ@>Hw$c7W%H_RD?_W8)4G|vE*5<u1M<5AWan8qX$mKK&a?uuq!7bU1@Jke)NtX
z&f|49qLCO{*I~j1ckqa71tu0r03$KB)^T4s|K2wdK?h{YJS0^<*zNmeSB<soui?T)
zX_bKdz;b8{hMH_7Cl<2bI20Y<NkpvKQwW`;2oA6-a*c9dR5D$mf=WLRsECK6?<Zrz
z{B;+Zb!Pj_!>taVOVm=uhqeo)A%8#oUERPxuJ+?W%65frzV2MP=KhY}=p9nNV_UU+
z&ZmX+e;6j<xpvcHT<!i8^2~mNEvDVdHE5SU*qt*frJ>KClkj4JmD0GW6<%D$z+f}2
zInWYK^V4T<BEuxWBN5vfc5KIDQ=C-N4^#DHn6cy@QOt24aTZ%qL{1NoT?E^JS}|sY
z)?G5><R60*)E`=KZV}Wc--$NK;3n^VfUH=-Q>*->xFQzBbac^#zXEXDBanCszCP^5
z9{Z5Q+1WV>Jz6Bz20;$3V#PhHwc01)r`g02z!i%c8!pIgwX<9QbOBk<UI~_|02EVl
zrT+D-eXV0LS5D;gh%ORR!yF_t*NJwFd`640J}D43d`5BQr$ZrQGJ**0f~e*mSCS3b
z$Ksw?I(c(fDVmVY0g9tJX6YiBy_uW>Y#GvHtG|0jcaoT7Q((gKxUO)4jJ=%_fSd^0
zQQ9?9qyb%g&!`|D2JlZ$bxu|@MWa=wGxaoc{}9s@N+z|tc-1=%8f*?;wvI9*+?-i3
z_W`q2>eq#vk>i<l6SxB+gIT;<o>;9E@YMx@)b7c*vkR#uD#@d-=v*PLmwYg1(7Q&`
zNy1n?RwkT33Kn$xLPmYphcK)Y@?(Su;CJE46N22IGD?L+BpZ%c&u#MRMY?1N3ZPrq
zU1_NvpAwk*MQVNnMkIX8;s7z~=fls=s{Kypm%qao;GL<SB(L>n1r=1DB0sP0Uhy#{
zxdw&X7?(aKE(>qO3c1l82Ny3UDp1#&AoHgh%7Rg*edgQDj3bPPLxQ2^VT}88Cz_$~
z7l|T7hI}^lsQDH)@n)Zp4V*jzNFf6yG?j_5>;;}D-m?d0Jilzqz6+zJ4&Ls&Q?R^E
zynoY$4Nw)|{CZ9_zQ1#{OBVuGIJ~+;BmCt5z8EeD=1c?<tOv=&V)<Mix+-ITVW7uy
z!M+kBIB!ywGAcxN1?9%#LBj;98cWXQ^zE-4!gC+3A5G(I^yudhw!TkkVCuXrghOXf
z>Tk)Qrn`?)5qg~*yDpo@*|IK>$>@J>Rk0Qy$^|2RKPV^rc%*x-*O^zk3izLp6rQ*0
z_-Q;6`9$wFM9h-?xD4TeVL2sIwBs$TRuDu|ZXMyB1a-xUu|T+kKEZvB$J(%*!(hPv
zklS55?~1J%#Y$@Ddw$=*y86|VQ5{V`6Ag@JxPY7D_t<Wt@B96kHo@t}=9b{!x)U_R
z^<-~(?(?j|xgj8H_qXYMY<cvKo}PPxVnV})tmxByvo5Mqk8b%8YKY65(Mn@vO1luy
z2fKg7mXL>IGH*$&G(jK5jV-fafM0+Z&$Cz<L_W_diBjT>pc&FZzyd4gk68!lrq{D-
zDbK(?VbErfa*@lyjZA6%&Y>qeRFpn0(Y$%abiK95(`t4p*Eols$7jTCO>OC>&)x2U
zhJX|!uibcD`9}6CbA@u+q}{T)P<tjE>=(RzjAQBdXLia*ZW(qxBs$c(4a1ujQLwU{
zOa*dG1>Nu#)*MYvRo5X@7HOqTRd>;Z(oLhh;h=>+_6R-7BG+sU>UTNXk~)pErNj_|
zE;{XT3Et$9e9kkCGudP2?M@%w5N_oUU-|ngN+K_iJE2o77V1x6(hRhUVE9QfF838@
z{=lO+A@EOJ{?cIZ3pxHxM=UeGzf$8ic2k5{P1mmu+kvm2lAjpwoQ+eq`mM&t;m6m%
z|9)v=L?V;O?#K=|Xh(WZRj64XAlY}F1)IvG(Y^1`#<4N&@=L(dsV4x>GR0jau`xn)
zFbEgWt71Yk_R#VPz`ds08M@4PL3CtmLN1?qFdBK?pV9`6HFRBNO|H_*3O<sCSc07H
zBT9A$d$e(P`cZ@7#11EW8T!p{nBAX&#Ai<ObGv@6cikmPa&29ZWn^bQ-U-|D%Z$(8
z>Lu%EdhLg4>1SaC&$>Rhz5x~j-ITEH)7u}#)cl_JTLUSCTX2JqE~&`qSrE={qBo$3
zV=!d>n*Bgp9V697`&EbmvN`lArWKlQu*wRfQ`V0Bo`}_RcXW;w&9!h1<aXB-d`+i7
z&7LEop>_8$?~awY#w5P;59sB}ZW^CyijuN^3whUkJcPNxHsGO%t@!&9SCKZ&6r-lo
zSh`_#Q(WaaZpT*B9aym6r_;6EU0dq#%Zq4%^9-|p2uH7h@Wx1Ds+Q@&Gb?=hu2ZNq
z*)8HDj}&gI*hU{9qy<0!aLuMW<g9vnMqgqq>vfHi*tn*36BtyoWXylf>S1P6#)&=s
zo@w$HPM<2h(M;h#%51Y;XRs?@+PntWOh-=disri8PIY6!`WMeep{(0KwOg(adkU7-
zF(=RS8t7Yx{}D5e;t!~No;H>7yR&+O;g(G*X8IT|sgHvrh_~s@7E}6pA?5xI5>Id*
z2j(vBlv#Wt{bq#IwP#-LUgCR;?;ImFNo*6fFHH*)oCi1|E&i$0u1z{r_0-P&uC4?N
z0D%C5euZn^UOnhZ;C46eR!En_mojnnCI7JNz-i4VbK_)AUst1DX#1tu?zh;HixSET
zRtGi+JqU{oph5%BC*wz+WwmtKKy3_()IQdHLngYh6Ri)u@jy8MCJQSiMMEOkX8!3$
zpz%Z+^q`ywt{tjiM28(JoK&`vuqDt6DV~LN%>e)Hm0GLxmqF)&xhWH(A>4Ya3rdMk
z$|;+=!TL7&SAK_1GxRDeFAR$Pe7v=UH;IZHi=>y;a#xk|`Yo-M$8X1Qb*%f(anOjV
z`5h90=9Vm5!4!XE)|RzEVZVR}{3iD%t?21$Hbj~-894L_6SYj4MLH$82+ig+II%N?
zP}*Nj`8Y&0Ij)IoMFZx8VbVtd-;tp|q7syXp>Z|$<4}u&fKrH-Ik<5o(bJXch*Fvk
zO`7wqr4|)j8vA>KHM>qAlvI$Whb!r@p-}|OqZf}e(f7akgcai}nKXXud7BJ^Q&%1D
ziKh%EiR%8y+|i@!_Ap9-ilIKkCOc^x`pC0Vm+7vqnV3K0NYuYC`Z#u68in^|T{hzL
zR(Os91|t69qnNg>tM2?!1Ju4yj(wX(09@LX^JJGCU@jL`z5%cJ%(b=6{?ac%XPH?{
z|7=8gpM!n`3^SDpdMvGGL6TAf!R$Lbt;83Iy%ZoQr3V)f%hceVs}Gvj?R(_%5=OZG
zkjCGqAvqNx%1E8Nc@$79*py<W`n861OH%&Qum4Kt-2RYh14yFfXlGK>e)3iN!JfiV
zLF$les_t$V?o4_0W>2OO&N=C+XoRVDDIa~DEUF<VaKQu6`{Ab+gHnIMdYA1u)9VDA
z|41zyRHfq>_k!YRmWMN`v&o?b<Ur|M{_$Mm_q57(16gsQoK@fwl!trzrH{5$?Wpqd
zyB2PorF7Q{3sP*RO^h{7VC+{lyPE>!RWcYbJLD&{wRKdo78c_Xt)^SZgvExSJk{}~
zc1LoA!j{WIGU;lY+rLY%q90x|a)-AzuB&i`3p4OM9iQ|fDTqxk9k~P54J@53nGLGi
z6|~>OR>nR^+PD=z_Jc4}tv{B}u)gofD6?B%`XuBy5ODG4S}W)Ji;x&FZGjSm_!zc9
zdpvroF1@Ws-dxY>%9sTQvtIm~&>xP;(hz5a@eW6jCAfq8VnaW37zJU{U{y7}mG>m>
zR#m995+&=^VZ`7nO!b<U)3Mrn6VV%@53uAjeIeR}nwjz_6xCR*uip#0DJ|@E%)`9w
zV@RDa&*EQC9ELLM)8i4bf4#9W^h^!u@U_`ga~8zEf`19JTt-nUSq8(`)Q!Wa?cdYf
zf|4F9wT(bWWPd!1J1=g#+-PTqo1gKO$Z%+qg0_y!us#loxXH{zI8Ql_W)Ln{<|BtD
zj|)-+h3JL(v=&TiZD8H5f01I9Wa`&@Q(T7XP^Ymw=g8i_-Y3)}x^&lqi4$1)8np(2
zozS04+d&K?V^OEO)Hac2u^U?Uuh7)}h15hgLM0Fd7=khglgm-2>7PZ1c+=%V6$xDE
z&A9&iVKMBPOJI2pA?ub&$6_1a?3|>U&w+}TkyT1I?4qmW%&?Gl(bQ|S)5o>vI*SWE
z^Eh4(HM%M)7@WUc#=_7;9Eqw2j+^mW)uKmZ4k-Pp3i4LVV~1d^lsoyv?xsBgZ(~ik
ze+9=LAjuYi)+@@0=x5YUUe);l@8&EU)k1Zc%_!46@*QbLK*)VRCqbAi#mC+%;rL`t
zxnTWm_dHfVBcJsl|GMzX+qQNJp!;b6AAr6Pwiw2ZkR(HwJUz&g_pJs=XjH%a@?D5~
zz-K}busXd`IZcj_^_JnKDC)SHbwbZZ{HVJ`xzulCKla^VzWU+nt=h#JUqS}sxx>GZ
zB{o@#uV!uJm9*Pn1Y%2)j43J~*DFF9Ktrb01D^+0FD&kPMzudw&(*6m`7=XyM?z;g
z92nPmr_vcqxt+AwRz)mSNGc{2+j1B5YjTai*y~4|D8a(j5)Q&{u|UqmG6kApQ9;}b
zI9;J2VqB|UhC`JYX{KClFBs#d!+@O0yIjKfvrT8tgHE^m_2C^}`ZWdCh%mvI&}o6G
z`2LYbIvwl;k}WNR7P57G*gCG+6o2y~Q_IJu949ZRe);x1f05&=$b5be8TvUqqt;!;
zNAT0%Ah97isDXLo26OgCF*cS?JqPBqR>Cd?1d=Q~bmu`5+<j_PWINE?ft|U6-iR!g
zD+}{Vex^swB+vbW@o>FHRDR;`rK4>3)x-kd?Bmx1tYVoRK|sT+ID;<rbJc14Ie)$L
zLRMbKRq{2-I`~QO?zQOD?}`RAT^nwmRW)-EU1mxFp^+FUBh6a>L9Dx}^-lW;_}3%*
zb`OW`pb2rGC*>7!r!8Oi$Ldw`ZRc%WK9>TbZ6ue%W`u@Ncpe^=i}83IdB31qY9~)q
zzoAtt6dlN4NGA6UAx(luO}vR_Pm!7@j>e>ROq9E_fcs-GC}JxIl^MH4x($PwTQgp`
zYusc|pJH=&E3YX5MIsBE=*j$!BECe+zJQMacFYE#n}R}EHW_(Vj$JI<1gOU{VP&ZX
z$0GDP8Q)z|IeKRkHeqP8iSIA;I=L0@btDVOxvs}A)k*;_R?aHMtxIoX$x=KPkBcw<
z^rkU;qWbm&=bJHj1F0_E+ipu}1SpRZXu^lr+Y*uv(m!{vhUjP5j0s?f7J;;Xa6f&z
zaH_w}5-Iafg-IDmj9Lm}>pd8+pmDK!)c}Ril&Rc(qSju$v+fQCxfAS*Tx;_SuG2lP
zZdHhEbUwx%<@WogclH|oz81@|(LuWeEm!tz;z#;27bLosO{UWX_cyQWHvKnJEq~tE
zUX_e*>g^f0*<{|{taN`he@;Qh1}^C?gg@I~kh#0I(8(jTuW|Aw|K@S91sDqAwi(;W
z&;hM8omYpu=ar`x4?S*mv483khvyU7_5yPIbWSWuquRSLO|A-NG(p&#<C5wjQ`kfi
zY_#TZdg;Hzrv<t5Fw3tF_^k@x<<H7q9$bPM4y43$jEpuco5MS=?b#*P3y-R<y|r!3
zjy;53#FO&uw>=@}P7g{&$s)f?<(~nLM(BVfSMGUpl=J<qXZ~wRO1!}vcV4}kV;;jg
zl1vw$FFyNDJvKm12^lO$w8roG9EE{~q=lQB!hAylcKi~ZMI9!$2H27$F<iT3NnF1a
zOofr0iaYdY5$9eyxq>|G6_0eT)0l|`0%u17p{qXG_5}un_}qKy39Da!F83b5)#Q_k
zsdMpFsR5W1@k2~j-oI)na;Tls>LXx@mAEzA0;tZcsU}?BrJA1#6Nf}^QnBuPXJ;Im
zI9O4K|FHde<<kzP-{y7L$)@kC^0wngkNJ3T24w$$Mnd^HUf^Q%)yt!y=LucnuH$*g
z1AcD~=sd5kiQZee{<s)p3^7fxiA1lSFLLPEA=V1!7bjAmFz*Jg)=*~O`ZT@TC=7vz
zT6L~lJCN)_#e(}N?RP4tmiGhDH7k2Y(fz>RXh%dnq#?Q$dj@l%%TygRLpIvLK}|z3
z{{BQ9$ER=n%Il=((Y#2{qL)I?B$Uwz@%^=QPm#)-g?f`rcM=@Dm?mmEo+*m&qjLtz
z|5-t4E{bc}1k^S+W&@sIfF?Jg__1dt@eZ`fR?2DOZeIa-7O_wCXQcqHnL&21x&#x2
z%uH>0IwN0oxQq2>f{PVKR?DZpYJlppYOC9V8H-T=>b<N%@D|^goEmPd@~bcQzEeRs
zmbZ)F!*i|gnOZxQsss+hx53%KSuRz`o-%fZJ6!jT5$k|r?&r~0++JC&(vg)@3;Iq|
z`#lR%2g(HMvyU4~5Lzq#*A=bWO!>enjT7ij))qH=3hPHB#9tKoC1aJzAlVS)90p~v
z_Eyw@lh53J!Woa_&%U128LQr}XIw98Cxyr33t8)de^=aawcZI;rsmd^LP-#)V`*~v
z2EoOw{VDuv@*s#|LV@!blIM)&y%XeR8H^Z%`*+qBI3jR3H0X-Ebfj%50m5lvk;P^7
zisuYilDo6F^9Ykz#DCYc=6IYo{*F=T>p+8lm_@uS_Wp{xINAe6cU+=DatkOH=*^GD
zV~WBMf=jwZLiJ3BQ2Fu-V^;9VFeb(BG9}XfTyNk=8~3}qaxES;NcE2Z;;_=!2a}^n
zjZa4aHp_9{BV1OCVxe@9ZED>{R2sC*F{hKhugM%lgs*bD3tz(2_8|Ti_%e$p_oq|a
zME0jR<b}L8(ndq1+^}Zez4_>`(t!;Iz~XlKtFyX!Rda_~q!Dh=+44tpS7C?BH~Ig<
zUT{_}lgu2r(G>$UI;})v%|U~G65$(mTg3uIs+CuJ2OQ~!5AMOkbcri5oAbZDqD*wg
zD{NdrLnerBj1w|)X5hLK*^WMV*A7!s!O|$Bl7Q`QI4^ER!1vK(9`MH{8M3tSFcuOT
zJ~zFyPF4A=ihgCQrPW)A4FvYBnoGs`R9I=|!bP9<#%RtlDUzm9Gn-4eXBmyB>T}y)
zj2O9vFvU9?PgR<JEyipQGi!(vSzmcC34fvZuA#T&iO6jbkc6@^iU3l#{-cWP&?Tqp
zr4~&Qm<{Wo8#nH*ggwbF$pE*}?UIQ@ePr`qgrrLOV~1=7ACtN$`JXgp{47R^@=Ut6
zc-q=o7)LpQGu7>TjpObkrCr$WDX-y4qN$@M(tnxTBi6GI5KN0=ogfH<t!W6#>P)IT
zgiSXWeWOZF^M~goG^&F&HRpbMj90$VI7HJKB}DuID2@GAOdX}LFf?gaLvZ?o{a$P7
zvShIk{-@{q>h#>v9}qVYgc}{=csAZEk-|>?T~C2)OVTIs^5M5lgw5cC^x_11WEt_=
zr-1c_J*`AZtIZdNc%z0(WP5K~vF1eNN}Xkg4vDy_iDfMIb1z`>mrm-!&^~WWz3_0V
zJ>}E7j-HpGXJ&b5MrvhQQ>fa`p}Gf2HRN(C52V)8be_5b=^2cpKW{7A*U{1G)KvUU
zun(v}oVAc2g$M0q?u+^(0PJ0xZYve;Mgr@m5U#ES{L3XX$?LNfnKCeuf%WhQ`CKvd
ztk5+vR?K|XeZ<knPtN6<$@Fp?>q-AODvO*|4&CRu2}b|oV3+4Mdu}kqmtbjLW&UJF
zYCyBAR_W>YOd{F01d`?T)AW%&UKZbXMw{~6ygqzbj}Oc|1Izl>>|cZMH(7pIT13V7
zqsFegTRH!B4)qg{628QJQ$17j<-#?g>;=6XAs+6D;NN0U=JPJPYk{(V3<S_J(8g+X
zh<>+y&iP{uwGeq<*w(S{5rL1stpVq@yFZ`oX+Af#HF6d>HkX0km(v}3OQ(8Wf9#JR
zj<7)Dv{_dsX$FLpC$Cc`_VCl6z!(V3l%|(qwH53^?`2JIE3Nuzw#)8j^AvBAi{n6=
z)@5`~Zw6GVJ}{fAqD%RcvC`}ALb&r6FG{4VZ5+lJWp^PQh}@!cY92+0Hd%aQZ&@ef
zglV@7tbWqOLf9^X%k>s5$s6rpT?<1wV66_t-{qRDOl{Aeb~`rsPzi*!i`=Ax0iBte
zT%b#M(&|$PLt0)r6BvT9Ue7uGMfA3E@-pqhR#bwcmsUtVi<y$Y*(VPM6cwDEXTBK)
z;V=Yjk@*zAi1{JPJImVAE%m_;X9XM-CYjReI+<XxjSi(mJ(Dq0Py`)v#6&*tnU#~R
zekH5QX(aOHaK+dD2>l?LpB2*_ve1s0a+!HPECKTm3r{ZP7jRd+*NM$`M&5F%V=o1Z
z5DRX_-5!$%^E*1plQm%u3kTPDL_$#rAU{1XNAP`8ouLQE;~q)FI!0KRi2pfKH=Zp2
zij+IJ+Ge<umY33HFJfi0$j@8T`qAoy|5JdZ;YJN)NGXl^<zpnm3ER}WsaEPQ$puC=
zdNr0^wZlczjUZy?(r<A`^`YbluRWnpm9-IS27$;jD%tSkfs*aBNEtpu5<->)ZE-%w
zaw^d!Mp`o$^xPEw{gECpxFbJ&wAa|XQ-S*Pnz{%5%{Z`@&~%kLhciAqD@F_HWZKZ$
z*2I<Ex4B3f7U81n5n=L_M+0X=H$>XYT$A(t3=$mhi#uWm8d4}7!DH{=A;12eBHTq)
zVP3+wN>weaDD>?z2wbo$N2Y|RFmQeB8waJ%RUQ*p@69A^mO0ltG}mG1ah@GJmQ=4Z
z{q%Su!~>YV{gTt$ZL6lY*Dp=}VzI+(wUZ%1Y9Fc*Do0FN+2&$+kDj4IUpjLi*b8Mt
zoPY0Joy4qXJ?^SpOg>gTl>_yo)b*_@m-(0K=SWcrHOd)KtgubY|9o&c@0f7J^+a(-
zVjg)ef+$k7N@Oh))r4B8VQJ|vfX;7%Pa!x2nNU(n1>mpxD54|TO|ya~>No<Zx7@j8
z7%kp3(LRLc7a!6&Q0=pYkd7u8pa0Ytp7p!GoZl6_RNx{VLRtvKaGe>uy=4-=7HNpn
zCA+<<*7P<q=1Q5(^DD%(r8tFFE7-%Uw&hX_aTs`*kaC#{fLWMskV6s91r1`fDHl*<
zlz{ala5DTBG%n#hk*0*?yQ;Ne&urE@xrHowwwP3UgH3jxcFJnWlwF@B*GWkYtqJY-
zsFS>NFi8KY9wRfUFx$SOW4~pcdQQ1nw%k*8tIx!ef^05ClQc_(z3Yn1NhnqCA%xvU
ziph=*F1v!_z7xh6h#&7z-aWd#6R&81%HAa{u!gK#XC~$%a^2?%S3RWwm|LnVs4FjL
z9;#lraoD_z;2ph!4wj2G>7F|K6EB8aVlk3L$!m?R^{Y{>sA&!48ZsDfC)!n-F!9iM
zg6v?(iEzoY$FzwZzDd~Z3&d=ByuK>kziO$s+@-K=kY$Lyw>tt8y0mGuW%;78f2{td
z)TpNZLqo)ql-8&)=rJUD4Jie=`(wcHvfh+H+xZE>F>IDD#L#WZ0J&%)RAdv2GF$vn
z6K?D^VLVZ19s@?y=?$c29$8R^|NP-3&7*lLktm~KszLPFF^QdA^%&V-S3;+!{nPE0
zf%7w~qp@<8!<~DT&9}NLGsTRP`%nNW7L|ot)Mi?|{_iCRPNYW_g=N%~CW8Q_RA|+L
zS3p5#vl@>5Z1v0>X>z-RdpwF^IdR#ogsMG}e{XQMAvMa5@pxivn+wNrR4eJJ3H;v#
z{Nh9E8?VQM_6QNp&v%OKXtVMW$GHCqqb*$0F1BFhf|%}lehm5s7Z3WL2zJT>eQ2rY
zfpB(?^D}kP&dw1a;n(xyxZA;35$ByYdEDurwy3jn(KMD`5Q;`aNf+5E=L^HS@R#y=
zd7R)kBb`J!3f#v_;hj`mjB@5uVI%6QYWX22tJIsdRL^R>C1@rLb0g~28@P!G5TXF0
z$XY{erHZ_34np}b!yrIr@}i@`uNy1<jnPtTohTXV<NE%`rV(kHRAVr{&nQHVhxB=O
zliO~U3wFQ3W+yvQmubW0OeXgu?{txHm#TNoiyMGyGzd`^@ff-s7lGyUOewDzsB-aa
zk2sZlGeM;fJ<~l`5z)ri|JsNcUpM_!VTH~qJx}L+*Xiu}fyG(6jSZWNot7Uo%jZoY
ze`xfM2%DAJ>U(hQl#9jgb@?J0@e@J#A(h@dEs#ZWJwW(fd9YBR&z~Wjx-$OqK^wLs
zlt8~=-mtKnmC5GQL(9gMHRY<^kE94!$pC?pC5%zaeS=WBCIf0bF`*3*xrakc^W!Bc
z%r(5sH6Qnqk(AwY*kqIol|5A|Hs}E0ssSFA%Ak0~at%9IN%u0(kE!n0@+Em7)IxNx
z+<o^eI?~e864ih3m9KoDkF`vBmmGkUpF;`{`763t$iP4-Ds7^4lN1dNG!DkLUQOxM
zTVsRo*0_Hir2KsA)r0VqzawoDD!#2KL&b$fjJHSZ?1~{f9Y7+9t<QE=VSE2@4K%?h
zH>cdk{$ZQ3VDGVSNp(B$u|@=1DBNwa>P$Kc|MW(k)lyoiUQ6iuDxGqhU(?6o+Nh^g
zE@+&iN4UQ<5sIX!ekr@Fzx)v?!f^S~<lE$TRoC)n?It9Ztu54cqCW$!Yk@E-EEmqk
zXkNVD7?*&x1ZtRBy;04~S|W2Mt)!3m*#7|h#DdOXbefx5O0TsvnVm)qmCdecpK>jk
zO|BN_v}vW+rcHCUxJ+i{n)Xeb<_N>}iGo<*zXH-|I;%P(`B3#Wxk5gqe5vF_uBgg6
zA>js0THCQ6=54cLnKUB$gINmLT{BOCi^RZBabQN0v1Hog;=F0bMP@%I3eU;A$OBHx
z8zw$aO>bBx5L{9y^fdJ-tfnZuQ+kKCy(3vaFxyIxv5C6MX8ynmf^;9oka<lyDX5`_
z6rW{;%?Q#e_r;jOC^&lVx*~{PT<z$P3s8}W3TTy4J2IuhYRcsW6Y>NJ!!OTL_{n)(
zyo_E2QrW7PKNVfxG{veEV5$UEY@vdnptvjWm1U=5&!MR>Y9)TPD0pBNGm11BZ|c=B
zBr!EKaFIf3syPEv@;jcX;*O26sHmg_r{eo06cznPQPGDKwd)LAAdSr6+$fXABPgoR
zCNYGCmzb`GXrLVb*B7Bhw*!SThr57rtvCb~Uk=9v;oRj5xJ3iSRagbU{CNf;heZn~
z*Y#PXGA@5fvEjC({Z_A1kn#zsa3~5}%C|Ygedfp$N{rCy{8&1Nu^+&@Md@azN6b@~
zO_t#D>ZiypD@*?tR)XLW<tVlQ6XCEuSnrf2tHWWV+e<}KP~iF|bX@?r_*S;6yj-?V
z4ZIcO_Wp>6oC0%R%t7gUMVGboW6B0ii8;+aP91rfR>Ld`4OqHeQk`NX)QglRgR070
z)$P@VlJF;Mvv|^&L${1i+<4@obI$4T%<?=b_nlcgN_+O)?f3tv98*@A1K6f5%xhzc
zl@@y3X(;Uitb_}2UAXJe<cS~wYoTtaN2EeQ<I*JI04)}a1@!*n{*k|D<=O$~gn%PV
zur<K~1|&AdlvUc1Ksu94t6=0%JP(khM+?N$Pxndn44KuhxMJbwm?y^0j|%suWJjt_
z0}BVGc*R0~EEL|tH^it0CaTQI9E>tQ#|A{tBmHRRUlHpe-$L?cznSE1yzb=Za)|t#
zSbss{Mx*=_;OG9EO)8hOygv8p@_Jz&4WWC|DlIFCh}h&PCB>A;e<ZC@k;JgNlFH}X
zx6=Gpc`13_<`OR!J<ie!7#A0dF0zkt>XL*9Q9w6C$zhcRXMMes3RZ_K@Flx)p)AW(
z`o5LHg=<!(#D$1)*p7=41u5p#&`e1-Hu!E-zy_DbHDP-8F%l)fXi$(5LA5Qe%Ggt2
z_X`D4K*O-4gx6!tW=ViM`O<`9x4`ff1^Dhopd9&pUa(9#ZgJ-<7Q<#L=mQG3fdAtP
ztjIwSq_vaYg(<?9)hKN89%Km0KLoi@Dq_8;RGg?<MRQ6;PzQwTym2g2-XB`Sc9l<x
znXSoSyMH+tg8&>4HzCBBpG=PU%2upEnV~;h{w?l_Q0RP;yH<>2<g4987$4q9>BiyV
zjrVfuDI$FU)E^$XbSGawC&OUINLdpT^uU%a2Pi%8f`)f9m1&ewqjzLBK;nwIVpCB|
zt%A>2^Md51{AA>jLfc*SAGD^xf081YI8aC_mJNN}+Gnkz<JFL|I3H8eb(~FS25r^F
zWa>{H5)5}fr%wHw0(z+=&`C8YR{1Yz#`Khs=U~j@uP{kyTSki|XjTDbv{Qx|j=+j;
zu)y{K^$JbEdvu~d0!X_!;&SDpjA)2-u<cWT5cn#FH(xG*ac}veheQ&agZU&Yv=s2u
zq682vFI`c3f*wKLHxDpg%;RQN5{cFA+Eo`L;}jRE6p;El+%xGt*+r!YQY;u5%qWeK
z!s(X3FJ1a~Nxs4BvaMNTb9u}1#0#Rp_QiEVE3w(aTf~UV|I=awXh<Xj!;?f1n`%XO
zPnLvHpvmJrE|q|Rd{jP%t}Mz05amjDl6So3r$pYK;!|djb&C>^3IcS$07QZoiAz3e
zG|3M!g0s$KBs*N3zvptVs6@Nzt$~41GvCcL^WD8-(u0~ie>UgAEmkqkym1`5FsunQ
z`UVoO8++cVU*2-S+F*XC=beV-E!S8ZOmt!jJOlqqPbNuYizRTLNwg40oBYQXQ~frR
zXr1K0iZi6IY^~%NMYCkJ3QlH2LpZthjKNvkH5Y-9?@oQQaU^sT5Xk?T_P5}0)6M39
zS=ol-RQJN4uusgXZ!{Qu=NhSTqHDsh^TL@)sQ4u@+*rr<tyM96q->95TdKJ{FoX%)
zSuHkspTr#FW~z;v1#Xy8EK$HB6mV-_G=sr_p0tMauN?F9R~7Xyj17KW;GX^XIsRIM
zgwr{#hyF0~AW(`@Sg=*IHdfv9F?ULxm{R(a*M)mAN&kyFoFVLD)p*a<80(od+)euw
zLd$+gF=83Mm=J%b4tEgh#@RoV>WcVps*ye}kCXg3qpNTtkMmPSzkgIjDK^R^D%IgP
zrqPZu0U?6ke<{L(#2N{1@Q?t>8$ANKGoB(oDZzP><@;=gjNmFM`5AIwQ<Hq$WIewQ
zH`z6jCCy8fwZs)iw<dR~K>S8B50_NQ&ytIqmN&yU)JX$%=Ua_rNmJRMM`HAqMGt1o
ze|dp0%(N&1hO2~$@N#fY@a573>f8W_egt#tBS53baLoIH&-VrT3=)}N0H*x5(fDn^
zm147_;!`;RP16iqC$2$Sh%0kq$(S+574%hG%wSt#B<89xO1YXB^yo#4FS-|guL50T
z-NPCJKnbHZN)B_c_Q$IZ1?*O6r!e<=EKT6r2U#GR;A5I~)fy2(Q2buMA^u9-4kND~
zFs*joNSa6d8zg!cn;7UC86m~be$`y}Z%2>36_i5qhfh8EYrXgb(oQxzUwHG}<2TPW
z$PsbYaOgjde(q(R_{6PW`+>>zx2GQc3z6@??S-xu!`z#XpDR?gBhnog)4;-f^+nQb
z?5kre5X%Mhv1BeR7EegP1eYXLz47AW+jC^$;%jrj4lpfhiH(+tzlVSFlQ6H>PXQk^
zvVGPfg3w7^dP6eq^634!c-9|4br{~@-mu8MP+&ym!w!dh*i826^<-aj2WhF7uhHw}
zYwBA_2f0*|B3XJcLusaT9sO2@kc^mUE?rk2)8TH8x>!9u*qm5jk`!L=KVVKvtbSIY
ztSVP6AYYGbfC-DPllI{*DEQN(JtCP4KwS`nj|0zKERA&@LT`yfou&1iWHvt)C8V!F
z*%6a8zzz8ikQ^^Rue2}c;V>cuix-E|CfofGP$G9VL0O}gWsXb$6cgOR06j68C8PxC
zjAUy#!9)8MIbJ&tke$SkUJYk=6~=F|`HH&Cg~BRfC%`yag$c}}qQZ2kYR=>-Dq+;=
zRVy_ET2U{jOt6IN!3-57|I*Xr4%JyqCQDt&-P3dDq{}-8CI5^DJN#>y;g<%hFLxq`
z$uS#4X&8Q(7L-rr52~{wUgcU+@{&KO&YO6Z>jLL;^UAbE|MhIUqE}OK4(=B?C8Fsd
z91WpPJkB?y2=M(Vl4Qpz2<26dY3M?RSOU1*<jw=TZE?HpjvETq*aXlE2)@F@=sA7I
zrbAAB%=z@WWuJNU3A8gzh<qcm4mhe&w(4bDH|8)PLx9<WF(yCPey}Ck=|}?i3<oY5
z-Vh5VB)UZ+An80&s`Jk#_dEA|KkhJ#^U+g@uLwcndw?DgEe2cuy(an1q|-r)_(hTJ
zttu?&KWq8?`520f8HKr(pE=Rb8%ed_dTW1Qm&R_L<SQ14s*}w2D4V1zyg|k5KZEWp
z0lrlk+Us+1eqg(oi3QG!g+^vYxkS$SF@7GMH4<V=7g&O|RL?JRaR$!iip`9vE<4I9
zUDc*tz(=@#fhXltjo+EX_BRvaj5;%@ToDd@ghQ`Hf?;vASi!QgR#)bGOwgIZw~hcp
zXJZ2km;?mbf9n$>Aag~w{+oDQ?1hmyjeE2cV|j;nz^ggjLct&4ySTv2ggKcLJs7#w
zC?*MR-<QDX`)G(xQJlIVLWK*-;@PvedR*Oo{d*%D<PS`Ge}jp9CVEkSU$@J%b@r@L
zl6l+I;MbepOUgI;d%BwSet&dv%0|EF_v@Ow)4s4RwU`VZ9UQZo2L}c&Y^;y<UQcee
zHq_~@r8j$H^^F$}3=EpBV}oQ3X}9X@8m#h5eexQo-aoB3<@7t|EN*b7GSmF}y86*!
zN5%KO2%|;8+ql~=Fpx=KHUHC011?m50Ph8S4N~HY0vPC3u~<M6l8onNAzx6<d~U~U
zPe`PzjmeM|-L;c>wD+FgmrWZn*i%-8Y8@#U)>e(zibuu2Xf!K_RUO7;PFJe%xLM?R
z4=RYDVwhCoOS66TI@qpy>e3j_mU8X^_)*ljC{L6CB%-85;Xx%8bA&=Ima>Z+*Je>k
zv*|MMq1;&t<ZToO_<X@L24KY}O@Nl0fw3H`LqDiYe%Xn8QoGV{MJI*(Dp#t_S^er@
zI0bD02dzIJ--<@-g{SgX?~4zJBZ8YpWQqrPfLlXrQi+4$)_N~!19gi{2XMK{fltfN
zUcS$8`88zZx~o0%S{`hPrG8Ck-hKds3mn+(x%xUXa?Rz2eV5D6{!o1pQgV&DD5x5y
znWu*-qgmrz9YrX8dKmYg66NRgkXFB+S7O!CD!5Mv_30ry9UK~4tHQ%Yl>K9Yx{Aq&
zF_u;=r!8cXJ*gHyg%nN{UJc7{hJQVtGKV~*+Z{5e#>)yP^0?NPtl7kjG7(ymHr9DO
zbsMz^2Bvt$PCQ4mg_%(HZ<x@)&!ESh!OL64xHL{htP*<7N-vSwz(J&xP5_lx%!(B*
zqBNmO5^mIxQK5KJU?PfxOsK&Lad@h+j4F5o_uH$Ari=PdW714eV^dLK9+v(DOf8w6
zQWkX4Ir1R+4&IYr>J&aMzj!WS2A<lLuFmg+l>8EW`X@<^DEUFB{1ULue<I%z_}()g
ziO0lwY_HI4Pk=A(O=3{^0B+x*1m#ae;dY4NO$S>vM#p9y9q8+mUt0e9^3hRpcS_#b
z*9W)A?0CFwds2QE1znQpl;3stq+9JEpB-V(<(EcB;S=(?lzjfc(OG?cWH!8>NWcfU
zBMpAwaoAgXyWJWeMAX;JRc!x^6RhTY5$XA+;E#O~)GFxlp{q9~LNhr;p9|v`ib~*D
zBB;I)MuR<I4Y$mr!n~HSHDEUw6)%OmWwZ@rR~M+!>h_iP``lI|WvH1OstT$<!#T>A
z#iEj%{6!^qe7gv!9XPgm>~oft{0u?65#*wwhkB5s{6dtETby@3q8Yj{Y%*LSf=UpK
zjv&KuZG2D;6AbUCbow&1n*j|bLP?;~gAX!I<+!Rn#*m=+LbWcJm&@&FwaP|e6~#C-
z<ku+sacr0IaZGELkFgvjxhPff0hE=G`%NT$0apB^dV{!_%!!Eyi@e-YUR&cO$5X$!
zw-d`ki@t!sRSGP9^LXbk?$gMKs(xIt7fz*niYtEYP}P_cZVXk#So6Xp<CM4ua5f0!
z66bSD5{CQ2-xOy2afVL-u_2}}eGMDpls1UgZUYdNp&6hdjLBLpF?U01Y(wYRSm%bZ
zRIT=Se=M~@eeKjfXslU2_J56X-{2LVvOH*-$?|zwGcZ-#YlqonTL~LqMK=4UFpOx2
zR$1o3k};MZD=PwgWni*j8yi}XpS-;w{|^Zlh=xEtcD(cFFtUn376h0!Wydv0WlMQn
zRhJd}n6floSfj-2plf&sK@Uz@uB1MObaL6jk13m5e5NYX!MbnC!t&B5s-Ffu)hO#Q
z$4*lhrl2OiSH?G)uFQ_B??^<9cruY>%!ND%DGMV3)iW5~AjCbTPvz+~J@SKvU(FO<
zR=TXz&uB9M02402y*5aZsp$cy|JrKDjof&I5=WkUYG~FrVO?w1bn4noSuH;HpA{b#
zR|~jrx`o*xgj~Rr6azU=AO~!Ko^<1C0N<-GPQ3V0<o4|^_P;3aBn8qf7d|2jNb#q8
z$*1<p(w!-~Y45pvOV87zPn32n1Ja?1m(ifC>QHv+<eCsQx)^TsQZstS_cyS8YQ`||
z^M(x=2YPG_^&^LB1+FNt$Lg#6GZx6>-CE%*H1R6}LXJgU-XQD{E)fp$Ha=zdLSF>P
zPlXq+_Fqz>d1^SffaW~+3GYK+BF84PJ-`63@POHsUUgucj<Bj))+BG7;XP(jEiy(i
z%u^Hs`I*)(!S2Y)-hjo^9|S?&V@jfkzXdaiKv{q_sU#OF&lu<EF=Hr7f-VFFQc#`&
zSon;%2n;qhD~Y7;2{+E4nJFEY*+bm&jq!|s6F(!GneJTgH|hrokH3?PBXfwlka&XM
zOVEepe>I7uzInsCNc8M{PA~tue)00ODY1BIh@>2evGp0}(oHn>Y-8~JHh*gE#_542
zJ?BS*zFB9@i&>kV?OM|wTy@Tnu7ZPzx`(ph(byZ~HO{qLQ<Fl?3+*n{wNj<6PySFm
zD%LaIz0u$gOu5_ltGjffvAnDIO(w7T2Kf)edY$oRTSTNKv&UH8zWZjQPCrbXH<-Pq
zn`{P!CsXi`M~U;oA|g$phw-70l~`VW3?1MN;Sy1%q0{r#XxuM4r-on`g=%#T4)vne
z@wTzEC)I!+9P+oKvsC4t>ib9}B(6+KRqRz0KyfFuGF;>IM)+%ok76@p@TW#G!wl5%
zcrE?CP!F7ZZp!d(r0GAV4&c^w#njQ+%5opdMNXy_VSC<HOL^%@*W@(M+3Z(BkyTC=
zqu%7(xJjHwIiWe1rCLu^8NbMECI%eQaXxJ0JWy{M-9;0u7ZG3kSTMGjx=Ai6mgd*_
z{dF_x;l0EeZVfxhzFX)_yrr04B|l)$Kka|C!5#K`!|ujMFZ&Y=7{5@~qlQI1(=)8$
zi{MJ1FmkN&1dz5ONG$4{Lpv?s5$2SY=P|_x+gW=(^|%^@jvp^gnOfZPUr`V6K})6u
z{<?<=8Sd6b78=*Abz-VLt_F~ohH|qY)8X#!;lovyl}fxaE3<=YFn~wdyp&0@DPpXO
z3Hc9OZ7l)oL2KAbj81EZQ`AOuEd6$?H4w5LwBo}Kt5c{hCoy$7Y$rPm7Jx`0$2Wzm
z3PZUQinQ(AX$!PKS~tz=<mXjI%U3JU`MXZ*v^st+sQXanSzH1Z_YT!STEZThdi_5Y
ze0v8*SE<V#R*eqq^(j8lYcHkr(zPrN(>24ZpB_8%IHivt+3@w%!)wS_VfM%4+1A72
zC2N&8FR9`QoxokU3&P|X6lbenKw^kujAl`ToAN5d4ioCcJWHeHVbS_WOUUwhbJK%m
z1XKz5;&A`RJd5RF61CZ$u@Vw5<Cv=cNKWdH7&gEFE;sDS&c+3cXba1*ef6q3<a2l8
zoVn8<_Lonp`{F|FBX%N)N^DMv>0x@SOIA>=*YD}gCea>{$az>SPNUBdV5`ZEq5)%a
zR)IlCV>FtMM&p!ZOfG|&tkXBO#LjLZc&WFDYmH5@o{?(E{@Bvn0)O)73|{kWvxVv`
z#MjiOBmL8z7Is5#l|N`m8cE-zv0GmavB;zI@NFKepo;qI$fa0i`Ifp#%`y*ehyDJl
zcy0A)ch=?{h#CSOW}ty4C@*=co)C>u8lavg52VpX9=@fjq-PS1fG=Gxz@3c=Ss+aq
zt);1|TrZ3MukC%cDCU8d<{=>yY=J8BCDj1%GW}T1IeD9TcxdR-@UUxmQPP^NMhNqi
zvWX25DOou@yvy&MF|$1y^358ZQJ$Lxn49RQ+l;=*W$!3pvm@2p!N#aCp;2Jc5PGte
zH-B|dNuv-g4QqFK#i?g5)4a*{cJ#hxiy>Vx5oC%0THJwCh@iKsE9UaB70a;<EucGH
zu_&Qej|5UZzmyByYesQZJt-8KHMFR_C<{O$aKD|3F4Dtx@<jT?=t4S!3*Lch8Bj4t
z_a0G<=-DIbMDpCixyeN7Jy9TE{30i0Urbzj<-&zmUMgoLjoe<Tmk@C=KOpua<3%})
zJPS(y=!S<%mNNZkwk!O`!9Dua|HK&Js%W$iF^5pBV39fD;vPzZ8Ve0qbfD*tpvN}z
zq8_msg%H#?vds-b6tQgv?J9*L5$4Wrj>*O5rsX4U-|ah>-*fG}(gfMD?y}ENNs?%I
z#jQQ{!wI(|KF2@w^{qSa3b<OgAHMj!E2$*jcdbhQxgV4XB|@UKV8Ek}V?f>XvKt?u
zCyMle8!vl{?q9cVKV>PoXxcls-ulk8uaG0^El8C--(mBDVP)XPq2CRtaxf%~$y_1{
z-Ji`RbAfCi7ZA%-1JKcl!G_(t8wPU^=GLr%r0VJt?PAUAq}9dM(&qeTzaJ9qh5VSt
zQ9?_rj3CFmm_G*UcCbfCl887yisK~-6^2)&j+5D)^6!U;fKFX>LX!WJ-}(djSw62B
z7Z@ubSoS?kGqs$N#h%SE9!U&DsKWTag}wbm7~M%R*~WbAI8#4P!{g)sUtQN18b=Yv
zcV=#HFL%3px4ZWzIon(=m&-M^sYx%_)8<b%g<8~FOKIBD)^dHxLtlhei7&#HSek-@
zDD=tNeUMNnf@q<F2nQu7N*^?!B8X}5N%X~tSnO?_@0;Dby|W?N-I=}F*}pUM&F}kt
z-$#5q2byyv-)05QA^Z;JF{X&nH7fs0Fn%Z1yg}D`bGflx?w^j$XAQ7XS|X4h<Gpy-
zcA;5!99sA06ap8EAkJa&xv)_t1GMHiO{Z34*a%%-q_b_H1^^x>$yE7^<qs5wRv7I7
zni#6z8GDN_wAZ)dfCh7D{_CCZ^M$-7tU}s4lR9$o6pZ~RfhCDmt`uax6bmzfuz#SG
zX-W<BItT>2pw~M(z(0PQHz`Ma1D?JTp6LU2ljS_NE%1S$GB3gj`hbD1!;47=6Bc%J
zJvKLeV>KRMy)iu(Ykt^~B@SP$$5(&md*Fx8T%cc4pd}Xt<tSfGr_gyp*J`3_<!Qr4
zl>Z9`atG;Jv1!n8H|!r{8W%Z6f{PX-x4d!HMmfj=eJs|CF8G0m2|9FI!<xV;7K-n@
z8>%-T)Xa!y4~>AP6zo$Y;5VHZpwIzu1jt%;tybq97uY(uYqc5|){)xYmghlt57vQF
zF)yO|l|c7V$$NoikkQQZpj-2uE!n;~IO-u9-qkoxD@CM6BiN2(UT-fxIfZv2Rm*GB
zMQF{VOQTEvf6QyyyrU!of&Fv`HgA4EZTv#qGoOhV2s|4IQb_OzlM&ZO?rEbvXR0GU
z$B(cGEA|k}$k@!Ty9bEd{Pe{J03A(xgS_qDbkCEgUh&42%}stB@#ctNYriKACLQ>~
z@)aT+34kvn%v&A57b@gRY<y-sMh?24W5WHHtYG4b_`#j@3aq%3Vt;*mp|IV?6&ZMZ
z`mtk|6&$-V3PRlgA>r0;4_#|cUF!JW`Dj^01U6p*0s<e~AOajidYxTwjg_rFSx61-
zUKx!wD}c1N`{f#%?+V7d*D-G*$WA4Efvn^FabQQ}@d^xI^J)XF4=<jeNI{X83w;{3
z>s>x~vyYMFT2q_-0G~qu1wflB;BRMZ7yp;;-;X^^r5>tTaGsa#5ab@M1W?MqmX@Af
zj_-*tM~Ifz$zeUM5f6vy;=2oUb&G53h~5Z}X<C^rFR{?F+}Qf+=(eCoOW69~@-OTI
zer+IeSKyg@rlsXh^RG$b8Ct~&mjziT$Xf9YP8T(UK0__N8P+YHFtmHVos~Ubrk|AO
z&28o;L=U62^ofg<UObUbXw8P6N^v1oOQa`{5OQSl1V~qc!hq{m+lGE=JARoZ2)kMF
zL>qC%;&GNzbbt?tbtf@@mC=Q>=H*kTE}Lf;D7!kJ7(jUxlIIrHlNTtxa8g}72L-7&
ze*R8{9W3uLfocV)oM2D#>5RPr3~Dl>SY`~J%{TXLAPxHmB@~HAnWdXPj=I^Pzp<ob
zLOmGk-VeYIj(ZUk-T)2-Ka$r)Hy-_gFxtwW(PUqgpMY1_!ta(@gtvtfpBv6fN2D{n
z#x5RtF%R14WIruqSZiZqH&ukARN*EyAIxNQbSniD*@<CAI1CaQHkutQ^fj0J3WHgC
zy1CrDYtXU=clFZKUg{mqFtUc>UQ<oj-omV@iVgi{FzfdB6$*W1Jv%r}On#)>&yO@A
zMb;s8$5h=_UR)!in$b?H<`glse$_D4e$BYfld!*EZQ)vfvo{#{bDsugK`2L3X`mki
zKDrkWQ`F%r$h@xZMF%Ac{{wQ1EV4jchr(5|>lzt8V=_7HI+28Isy)_&2$Kmrc>9HO
zqf_>`=aV}%GsMDL;+p%@ndAHEyS!LNkEVx|M0mFxQs}+oGmAN*#N}UQrflQI-;)y&
zMyt`W+3(K3JUXRDt!yMNt3AhO-sC6yKE#wlW;~JM#~$K!we-fb$l5MQV|Z7sI8*@|
zMmf)ACWgN85d#tL&+~vA%#fM@0|g)`)C@5FTH{l|1}Fjaj<CS(V1Hml{0P|Z4onvT
zc(jFyfvCYx2j<6dX-YZ_qe>T}E=)7&5K<@rzHSp5)949E_&BQ;_m(Q9;UVAWnGbe<
zOdTO%H*2{4^402dM+sffV__PK$BM?>-KQ(1V+XEJQzD1LD@G)mkadMjsB-+M>yo({
zg~>Z)J)BGSXh~UTcDt@IsQaUrT=(@$)#oEoUMOQkViTofb9eWRM8*jV>+)rOie80<
zzKZJPiHlYDHq-@mCbcJ%xiFLd10vsvod5uMoMT{QU|;~^O#A7&@%%Pl8Ms*(K;Xvn
zy=@5k|4bGJ<^~{_gMkSo3IH~J3rYZZoMT{QU|??e-@p*V!T<t5l92%gFiHXdM<D`(
z0001ZoMT~NU|<==qj(gLVj5upu{#810000000000ZUDLgE&<*GMgpt@9s_Oz_5@l4
z?geNCo(4Pyga+;hEC*}{s0b7YXb7?iGzp3c{tAi;-U~ttm<$9Ab`1CpKn;cs$PNq+
zS`PRRP!G5e3=m`x{1I3Y#u5q=RuaM!G83W{0u&k)MigWek`&Yx5*0QTS`~m5s1@!O
zLKdbM2p2LJVi%GZycg~m7#K(xco^6j4jE(_pc&{IrW))UUK_$3OdPNs6dhb0z8-8I
z&>v7Aupj&&S|GL|0wHK2s3IUDq9Yh1awFO#6eN};_9coYCMKXJ{wH21wkPx`WGI>`
z4k=tIqAB1iE-Iud@+(#=z${KIek|ZEDlLL7;4V-uh%V+YBrm=&7%+4&+%Y6E=rT+)
zm@@1$WHazINHm-^5;d+h{5DWFpf?sbsyHk-dN}wwHaUhl(mEbGdOEf{06Q!@cstfS
zJUoaz+&w@&ay`;M96oM7%0D(glt17=I6!JZ(m@VEK0$s#zCsW}q(bmROhd3k>O@{d
zltk=BGDUzz%0?zeU`D`42uCnSSVyKv97tG5m`Kn`8cAA7j!DKz{7O1XY)Yg`;!6xm
zI!lgBI!s7RR!n3}a!jsF@J%93kWIW!7*149XimIN@J}*Nc2BrZ^iWPvdQi4e08utk
zc2S~Hyiwdz7*aw~h*GXn;8Pk?FjHDnic`W<`cxoPJXCB{npCn>&Q%&!I#p6tdR3ZL
z=2kRTh*r8*-d6%wgjeiXMp(vpoMT{QU|^JF=waYv00AZ-<^n<nhW}tb0{|KK0m}e*
zoL!MSPQySDhJWWN9Ek!16~#(O6hU$xWkeS#=~K9)aGZ4>vc1-N6DcL<;RKw36VTFd
z3Oar}g9NcPGvE9>GalOjuJ8#Dr|X7xVh>$rCvK4Mgq!4h;TE|s+(r*0;STjDVIPl}
z3ioi0oD;m(1+zY0ggsp1Rk%TZ6K;~<g<Iqg;WlpZCETI@E$k!oy23p?c+dXS>d5Jp
zS{~IlHhE%l=j&8wI(G}b-lvh3OhTw_xiz^O1w&EhI@k7hMtN9|ol8_=O{Qk1YD<kO
ztl8<<1smsz$zYI(9>gZ&N>f;9L~!&gC@gWL-y(+L$4F}LSf`QFGFp`{7}wZSi|YQr
zXaBR1(W2zUYLenl2rxXWnb)zZJKv+kfzKIJb=*bKEazmTnQT@~O34aEeYT?#QxCAI
zy9!J&;GLY+2lX3fKVSxHu>b&goNZPGnB%$;-rs8qZT9WnJt{N0?OvIgnHdyWNz~Yu
zPm(t;S7v5rW@ct)W@cvQj^reJ_u714>=|h^8vMr_!AAS*Zv5XLPD6lAgoqF$L5dE#
z=%J4RwlKstPQng$aR`TTGETv%B!4>2g0tdmI6KaPbK+b$H_n6e;(RziE`ST-Lbxz4
zf{P-<#c*+40(&@uOX5<vG%kb7;&Qk=u7E4zO1Ltvf~(?cxH_(ZYvNkCHm-x~;(E9~
zZh#x&Mz}F<f}7%IxH)ctTjEx@HEx63;&!+_?tnYuPPjAfg1h2wU>JcThrk#UOd)Z1
z%ut|21%(<%p<!UrU>|dwfd!7?9=Ip&g?r;ZxG(O9`{Mz4ARdGV;~{t`9)^eG5qKmX
zg-7Etcq|@=$KwfjBA$dN<0*J5o`$F68F(h1g=gbAcrKoY=i>!<Azp+R<0W`0UWS+B
z6?i3Hg;(P>cr9Ls*W(R%Bi@8J<1KhA-iEj19e5|+g?HmUcrV_E_u~WjAU=c-<0JSe
zK8BCu6Zj-Pg-_!%_$)q$&*KaDBEEz#<16?ozJ{;k8~7%^g>U0K_%6PO@8bvfA%27(
z<0tqjeukgp7x*Q9g<s=0_$_{i-{TMXBmRUx<1hFt{)WHfANVK!g@5Be!A5`}2sVQ-
zh=Mpsf;8v^-JlorgF&zr41?|9q+lo54Gsl|gOh_(f>VRjg3~vKl8cOM!OBdlrpmVu
zcyqL2TBL<43R$aqP%F!<%8b>rHfbq~S!M<6xC6PC)huxot;Af7$3nzPvuYy3S}+~4
zx-LY_r$XyRch0QPr6^PtO*E@TUyHGp6QN1H-kGRTA?)(@Y}^#Z;Dn{#l5;z8OLw^{
z^45rMdwIs2y5sNh)KuBbbDgz&NiK{L+D4|CFx|0?6wOI}JZdzV(w$XuOxG(t>$*o~
zYNe`#PbHs;DjX}7$GJ4qY%g>#?}8w<5Mw)7G33<L4oPbzt4XFBTgXZzMp#Sypq{Eq
ztXBJ_(rYFi%ZikWJlvOJo)n7ZbRb!(4Jod(&?s%`&`1^{^O{Jn-Y54$BZ?w5E+V8Z
z&IJWXM5U}66V^pzBFX}vMZ8c(4DyU=t>&$z{T1h&=>89xt9jKsPCRYtrrw;1McB~w
zaZ?qF&qDXuw5smVe<|xIrz`SoIAVMjkCe5l?6<F0uD4|TwIxM7tGv7;x5?xtUu3Mx
zGt*SnVo)<}i$&J1$L%ZfTQNN;&K-JbX+*Y{EUE5Pu>D1*nXEd6Q|(gI^^{-i&Lyd@
z)m-R^Duz!J|IGFxD@&n!tYEryH}YA(WaN|L%t}=a+c>ZJKFjkpb7)0mvZ7)tJ-xkN
zTxLD03&urC<;2y#(1Wqm#%4_B*-TOZwW_C!Y%gw!s1!LX693HhI)>uw4c#myPe;s%
z5u^4nigTe;s#fdxE^W+&CsSjY&Zt)gT-6K8EpJLu*`DjF%ut7jYGCHlxjt$rCDkUA
zWytC7ROPB9S9Rzj(&tihDnVaVTUwN4`pTi*<({j$b@h)36pl@sa70zQ<BKq>l$B%I
z2BS;%I|r$tcWt99XJU4+me$HhC+7&una(K$#;}Rl=2K=fcf}GXhJGPeE8N&x^B(AW
zo;_aFpY?lP&wDbaDxwlkSGI(z78QX^RSE9w2<g;plh;jTr%gGE$D(9~2;C!^G%^?A
zR1|esiMa{QRMwHz4WA_=$rluwPOUv?vPqlvrn2#zvLkHBm8_zYTwZi2WV6iqx;av3
zBsF0GWopYzRb4w=8L>%r<V1FFxMt~`&5oRj-`hEncUezNsb;PjD#G}djHZg(y6dqq
zD_B{_hEJ6$h7<^A_o$JDL#Dn}(m`@fV6N%B6(gl)*^vWsFB+4CaX?+Pf$M0&h1{pp
z!&-6*@49RbpNU`Q#57cm%=L1*B12MI_C+r3N_Oku0vFXm&MYIqV8y~n)2XE^RC2iM
z*GJ0L4-Et7L$72L$!WR~z>}Fu(;{=g=|a%)^1ew&x-rv)P$Z|yNGau-3Yn#bOGA)s
z`umh~MNuWNU~!Aj3A0u+ZWBtUq!E`MQv`8japDPCQIRptr*V6#Z`n++Ia_2d-A(P_
z|48c4*HIlGWKJWQDnVA%hy7LaW`sHEirHST`qmWr;9!9|ez@jZ;5y*j9!^{wgf&}Z
z8YFItE|o0V_RxxJk93zDS+Ux1%_8!+<wBRJHD0*R4=JpoLlsE1LjcxqsdfbPTE<U>
zZcF?5VJLspUo<Tvj|e-*2Rx&(C-bDmwkjlln!yqumRot|b@fk_ZwMxhiRkNuva`bF
zWLby3<6taw03I(xv>fc|(MA}LU2X=pDr1vPwA0)Mj#yVg^m3sX5E|As&F_ZFVUdzd
zL-<{iu%+fQ?odH!+aYPH!HNr_xGG(CoQ8r;dL}EGru?|i0=kO6MhtB^sG*nZ?b!I>
z_nlxx?z_WuQ=3)NM^!7RgWMrPbJAC9RVwF2&!5yj1azXQoXK4hD42D_i|(W5p!wvC
zT1$4@G?37uw<H;bBWg`+i@4*yS^Q^2;;E+IrH11KmE<@nl@oHeh{!UUE6aK6qq()Z
OJM()R8~+8G$7w@B5lD;x

diff --git a/priv/static/adminfe/static/fonts/element-icons.732389d.ttf b/priv/static/adminfe/static/fonts/element-icons.732389d.ttf
deleted file mode 100644
index 91b74de36778b0ff8958d37d07ce70fb3b26f50b..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001

literal 55956
zcmeGFd0<<`wFi#R+^fY}ZAq5o-I8TXc48}DB_~eoo5b1oJtQPiNJs)CAp{6J5lc%N
zwzOp_v;{*7Wh>AEEu}1ivXw_Ew0$k5g}UvlZr_9Q=nAimZoZ#0BiV8i1AV{W_pk4B
za_-#y&T{6Q&pC5u#5iNj!O~1+?Q>SFn(?sl$R8Qwy*N8#$%;vx(*pCJ#PP*AUcF`4
z<~_akAK%NE1=l?jw(q=P+r(ea7-p>UON@Q<njNQZ-dg_tGYz;G=U8F~PFUYD-HLs@
zgEj8hb<X))v4i*hlrhuRoo8;@e933;OEDJ2X{CGD=JWR`9SrZ{6}Tz1d-JYSzkkhL
z$l<>=;`-)2XP$k|f1GWuX6%Ot7;`xouBhpwX?C)SUdphg(iHviN^u5fPaC+oZByyg
zzcGir_f7q&&(e)DJ$j1?Z^B^-Q|K)I@C=^5hPlNNoweg1V@JiY{F0s3u~x<hXD?id
zC*_Of6~6Pd?VE8fe}rpvmE#O&mi$>9n;1XD%&bKDoboEpUN2MrF6-oYR$*opXHW3A
zbla@0Ov?`GugLNv?`hnYFFk$g|LFhf;Qx5w|9Ig4Egpb~F@s1xwMucL{zJ+i<*ztP
zlBg9aBqq+Al$UYbz(Np9N_Hsw_3XE^&t%`v{v~@<_vw{-NS~-r*JtUQ^_TQljzYrX
zp2OLDvyW$=&3=&myRPVdy;^V3+w?(wj=n|z0p0Tne;nUyU=!FNTh1O}-(t_REPJ1Q
z#O=J3ck>-eJD%qdu`2RphD^3XHrtS22XitPq<$$YV;<&ZK32~BEWm=Sf>p9AR?TWy
zE#!VZN)yHxBCL@$u_%kNX4Zo5O+>ldSe#8_?W}`!vM$!mdRT%bSudN+rm#LXl}%&)
zY=CKOI-9{}B8RisY&M6@W%Jm4wty{Ui`Zhege_&ukoOgAC0oT-vo&ljJBh7h>)FX{
z1KY?pu@u|PPGMWvR(2}e#<sH^>@;>dJA>_HyV!1aCfmc#Vtd)y>>PG3+sDph=d%mg
zh3q1BF}s9a%J#D~yNq4Vu3%TP&#=$3tJnedId+g;4V`x_yN+GY4zbU(FR(AN8`zEP
zCU!IQ@-6IE_GR`J_EmNpyPXZOJJ_A<F#8(2i`~t>&hBCNvisQmsO@jC2iZgHVfIbb
z`y=d8_89v%`wn}YeV0AKz6UJu6nmOI!!qny_8j{@TIL1zB72Fw%znULVgJTnWj|#9
z&VI&z&R%10vR|+tvDeuf?8odU>@D_F_Dl9F_BMNm{TdkKH|)3UckDmd@6on8`vdz=
z_FwE>_8$8qTKd1)2kcMm&+ISkuk3H^@9abN5&IwZ5B5)XgyopQj&jC1SGdYe+{`W9
z%1gKn+~MF(?&5A<%FDQid%2I7b3YI8AYl?ty8Hj@e@?0Z7xf?fQLSW$BvG%=9+pHn
zD*H7_Bqg$UNg`m9y;~AVknGnb5h}_8SrVZyvh0152t#G>mxTVwvJXfi$&&qsB=lL9
zeNYm5FUvk83H_L5AC^QoF8fVM=-Vv&ElKF*Ec=Kg^mmqhR1$hV%RVLvGJs{jEeX<q
zWgnLWxxljDl>`aFvQJ2YtYF#iNrKd1S)gAc$Pbo%N)jXq%RVg$GKFQIkp$_&vd>C_
zoMGALBthb^?Dr)>_OR^pk|2dx_613hM=blIBuFNfeMu5z6w5*a6G2+B><=VCZn5ku
zk|4oY_TMByma*)sk|5Ps_J@)n-&poFNsx3b`y)w^c`W<7B+>@iHzYw0vh0r~K_as3
zPb5J$vMekBB1lP={dY-_mn{1;Nsyc@`*TT<p)C8RBuG=1{e>jRRhE5A5+p3k{!$WT
zEzAB&5~ME6zAXvzmu26P1WC-Yzm^1<%(BChAe~wEH<BQyS@yS*AhB8Ycak8xS@u69
zF%9YOB|)CEY*rE^JIm^lAmdr~50W75S@u6ALGH8ce@TJ{VA*#iK^L&>dy=3PSoV*S
zpdVQFeM!(1Ec<~Z=nR(qlO$*lmi@CN=n<CvizH|imi@aV=oXg!P!hBZ%YGyY`i5oy
zM-ns-%l<<WbP&t_Qxdch%N~&gy~MIPNzhO%Ye<5wV%ejTptYE;NP_-ix+)2pjOivx
z&}mFJOM-S|x<wN79Mi3mpz)YqA_=;W={89$h14zy`jF`kNzjZ;cS?eeWV%Zdv?bHs
zlAt%44p~414a)Q~NzkQC_eg?PWx7`q^efYSlAvjsj{Z*soy+uqBxqlz2PHudGrd9*
zG&0jGB|$ecy;>5qG}CJ&L0>byRuVKf)9WNbhcmri60|wfLz1A^ncg4?8lLG9NznC7
zZ<GYB&-5lq(Em)2N&*sKdQ1{90n?i$0Ua>CMG|lV(<ev*Vqp42Nx%+FZ<EB*NaK=#
zCzw7-5|9Pc+a&>GFug+(&<4{xB>{IZy-N}h2-DHGiGW3z-XjU9gy{)Mz$Z*kN&-@0
zdaop47N$>@1oXmm^lc*G7^e400-|C1R7t=#OrItRD2M6&l7M%ZJ|GFmhv}LmU?8TW
zZxaCxF?~=Ha1qmINdiJ*`W#8XN=%<CiM$#5JW0S$OrI|aNQ&tTBmq+~9sQOF=!)r!
zBmrkJeX%4UE~YP$1nkB1rIOeX(q)o>$C$oc5|A0wS4aXzWBN)-Kx<52B?-8V>8m9H
z!7+V}Bw#tFuayK;$MlmV0pBrwog^SVrmvR-%*XVTB?0|0eS;+6K&Ee$1VqU6O_G2O
znVymal*shWl7JVPPJI;w<jC}`l7JzZeySv(Nv3a;1YF7V?UH~nnZ833uqM+_lLXYs
z^wT8)e=_|HNkF1Z-zf>0l<B)9k*7-EEeSZ4>1Rp;VrBXsNx-g5KT8tJAl)koc$VpB
zO9HZG`Z<z-ahZOuB%ocU?~?@F%k=Xk0Rc1pd`ZB<Ous-9P%+allmvXt^ot|`DKq_I
zNx;lZzeEzyGt)1X1RTxu{gQyFnVyyeY|Zq`BmreJo%$*Wc$?{0NCNU^`jwJ^!I}OU
zNkHRF|Ewh7a;9G;2?(9(2P6TjGyQXtfZCaUP!jMv)325UB+v9~BmvVi{aQ&t_e{S|
z5^z4#ua^YG&-6o*fc=^Nc}Z*-=@%ry9$@+xCBZUa`VEp`BQX6&Nw5}}ev>5F4NSjT
z5-bR&e@PN-38vp7304KuZ<Pf5g6UtD1WSYIUy%fxgXv$D1nYz8w@HE>!t~oE!6IS$
zkR;e9Ous`CtQ4l-DGBxp(+^96<-+u@NrDZ-^t&X%nqm6gl3>>`{p*ro;V}IkNw9U8
zey=20Jxsq(66_zQ-!BQ45Yr!!1e=KI-;f0Bi0KbXf}O<lha|ybV*109$Oo!_QxXT;
z=--mWaiBjUi7QASmBdw~k4b{dlj+}<#LY;*BZ(|4{c%a$iuAjZ$PcPNA&J|Neoqp&
zBYjd5cOZRA5_ckfS`v35eMS;@Bh5(SrAVKZ#LJLACy9HIeqR#zB7I&G_aS{j5-&&k
zq9pD|`jR9bK>7noJc#raNn{_1zuoK!9_Q~Uvy}(bI(4`Dchfr4`{o_y+bmNpS6be;
zwpw>u?<pxS*-`Q>Tet0?&9EP|zvGzU_>$A)+~@qrrMW)qdfDxCuXBG?y0rB1GH2P=
zvLl|go)^7#?`rRJzE0nr<w|)~`Rwwo<zFcOso(2Q_}BT<{(JoI2WABB4weKvgQo<)
z5PUNDenmya+=}xn-mG+2Zmk@u{6p2<Rqs`AtbVR$Va>y}<+Uf*K3%u7?)Cbm^$*t{
z39So#JFJ9vgzsqxHQX7gi0qBL)mYwmQ{&&84o0KVp{Nnt82fhYugzPVZ*6h6oZNC}
z%kYHwgs)Bb^TgE?Gp&nTFKr!eo6vT7+i-ko{6IWAsd3WN?G^3kwEwE3vEvV&3p>Bm
z`Cix7t`EBBc7LVE+w)-0k;Kx(P%@CbueY`L!rqT2CnkS(@{gy~O}TK&xBFOMYv1{O
zKbtyr>Vc`hnx;+LJMG8)HT}E$Ul~XY+%Pb#^=hYUKc4QM-aq})=}*rnoAI?7AIw}h
z^WmBQHMnu`hQaq{b<Rr9dS!NWc4~HJPRX41Icw+KJ-24=+PPnv`}(}9dF$p~H}A!H
zN9IqNzjOZA=KpqqbHS7a*Dai~aPGp@3%4%ZyYRt<A1rEEbZF6&iz^ndT)boPlZ!uC
z(z#^clE;_)b?M@z7cTwwGPW$SY~Qjwmp!@cSIaAw&sct7`Gd=cS5&QNU$Jh*!4=P~
z_{Yl7%Bd@NuDo^Sw^qKks%q7gRm)dhxa!tb+11^v53GJ-^*d{nHCL{AZ|&^0FADD(
zQxB*muw0v9Nlhkefd>Pf13b|ki#C*UOL@?0i7Nv<7VXB#&S0P%CmSXyR!e!HGs(qa
zoR40T`@H*i_DcJ!y}eU!>3h}o`2)2JC-a)C+N!HxUGnOZ1q)uSSyH>M?SRc!Y3G;O
zY>&AcZLY`cRa@=$$6OJ+>oHq3zxj7I+pB%IOzrJ`)n4`9RW*F_!rB9EwM%MVU9bS}
zc(tZxecJ(hrO&36;7gCWY>jxsR(qBGQL$?yf56d<%2W;3cQaXlJlvV+@pMNc;RcJ}
zQy%Ct``x@D8taL~dCb$#li@lZ^ticoBKM<gi5@=0ncM4i8yVWdm6KSW;95(9^F&J_
zO>5p7FK4(cZZb5tR~u?cBwB{(ri{GVpofO&CFrG)O-KK!zO2rL#Xk`i-)!V8+Ry8_
z#ahSvc~2K|5a*L{A{a;{6VX`IVx`ML%4XE!lg$yur9iNbTirMrvs&tSQsJhWo(ayH
zDo@`)OjFhk4z5!!ExmH%#zPh5OIEG<Msn&km(Asxa>}Abdry9?Zoy<;{aT`X^OoPQ
zS?QfU|JR2r{V%xe5f{%?PjFQCOtjVny`5pa18=I^c!5>jx&GukTAJDywb*8!KX1_)
zR`WXJiPx(6<OOxF?fUin+1{0Fe!pdN_d0*&i`0hXUpabI-J=e|ixPk}yB?O%S!4z9
zp6)2MKo_{I!P?U$4r_T=XPshIQTQ;*sYd&iu1HV65(p-OGO1H)sk9TR$s#?`Nj%bE
zspnzT&_r&vSp6}2Yb_7-@mS%Fk<OrsI_g5h4{#-gmZ~oIoLcUoHrwNGs<xY^TDu#K
zRAaYws>xp6<S*m#c~czW29X3NH3u&9b=U5z?e=ktsd|CStJW%YwW`;(pxSKC;3b~&
z>NFCqsob;0r+S=x$mvmi$`H5!Z1b>ltJ5i(eaIV%`Hd$VCUQQpfe-p)A@5LAk1g8h
zax{t5X0D!4F_aEgRt6Po$t1t)w%c6(NhMZgnBMffbB}zD(|C28qqNkqEzd{KsF$m0
zcwXZ0G-y<_;Nq^%K)D6Agt`dVDVEVHjf1<AL0>o-=gEHb*d|nq><#4s?sF>v@Oz>q
zO&`RDYq5lBQnXoVtTN88YE+t|lT3+NcQZd=UV5_0x@);P_g6)un{yd#`9A~6IVDq7
z76PlIO1$n6?_50Docmr?nDcOzGRr)9aVO8@GIIj^SFG6Um`;s=|3(S#aV*)hmV9$Z
zpHYWU9u>Ufgr960`8v42D;bUiyL|qxIJLjED~Z04FXRZjL>i*qi9Raem@4r5!oIM=
zJ}5IXnX-qqk1DRZWpK0aaqXN3D(6tvwaKPPg|Vk1Vwx<9GN=5rn~Lh-4@P^4AL5yG
zI?eY#vwDZsyLs@aa`j%yc5g*J=k*o*WgKk!j6YT=c5WEJ1$Y8wNuw+^Xq^~*MSbW2
zGs)-TLmp9pI$jI^Z*k**YZFQ0eK+dM7ba;njm!3_<>i53mp{_S{S!G-ALzx32lnlI
zpdu9I(Qt<;Qfch1jF>vYI6Q@aP;u2sCtbyVZg|`Ibw*`TGd-wj8BI%T^ru{W_VtQ#
z{n@t8uomvJ8RzCpbhWJuCpulLcPPpZ<31zJGpH$1U2bm%4|3u)A;&W6#gMO5!d4++
z(Jb{m0PaJ>K+HyCDjH@0&4NzZoj^YfltRn~t>IYM3QqK?Nw?FZ1QcIsM~r)2##EP=
z$2v-VIOTD=tv;91=l1bAX2WhuDyP_U57-wp@dHgtrqr2s2XpVZJRX<Q5Ok-Vr5oMl
z<?fBLYz8@of!!egGsvF>KH75lKO5j%Y)4znB>z<t+977KnvsMp)+W@uH4yY6@dSdz
zR>4HF2}wASG^4qaC<+o!G)A|`CUkSP9}Uz1MW}?P?v8(_)XUAWxY@{=ZQNsy$G&4T
zUmV{()myq*UN!!St6VkPjQ<|J>M5K4bk1zfJuSD@jg5)MMm~Cd<mr!lcbRAO-4Bku
z>**Z7h|}v5jc6}a33xJv`luoQBh-zXqirw<p}4spg+r;jqmvYr54t6pAcbTph2p6v
z<%6ma1*gVT>P%)!q|(AWrk8ljTjS~-7himb8gDK4+GekJx+AU53b(~sS7V>P*69Qj
zn_j%>rWZ|oTEba+u5yXf<*`<KROjrD?WLiW@eOHJO>c;=43%#0n!70I@2av`f|XWZ
zbE$XpjK$5nv<nZb>fsBuUCoP^?m#g_Ii6SVRA<5)dos$A2zDWVfnY!ZFhQ9j7U+gJ
z<X<UuFbnjj0&xdzS+7vJNR>ie(47QT;yglkkt(GMklTbe7U$NeMOhmPslmqb$;r9C
zwe{|@U{6CZ7O<9<R|d)~{)j)ZG(JB%Idaadib=IKwn}@s-xREJn7niS^|mS>w>kYC
zRXyu!x+hJp(ps7lZEZcV=B~!2j%0FCdHodS(GrzgZNV8EjaSca_IfHSxy`Ox&E^ut
zX6K4FXE%49(LYDwPNzeyD{r3Wa+CyJlh3OT*|@jL?y`A!Z~L}Ct!SCfO|^<rr|?9_
zZtj@dU#X}tEC~M`Ri8(DccZ_8<2e;y2ukXRq$5PBsq#=CsHX(DJPPpx976So_Kqe<
zTwzC?q%Kp~=Vt$e#zp6nRN!ga-WZtNnDDH<u*AH5&6?{f0}GcoH1}1lnB_ZZyVbFO
z-TDix%I<R{nh;yXRCK_fK5gE#6;dJet>~I;F`d2kq$@qHbt_lj6pl<;IepFfYdi<m
zophGzymjlYFs_r}Mr`|w_62Wzpng&HVPvHSCAP+ru>x7V!GI|VbcMF1vMTqtPU}hD
z7QemsMbF`DHaky0lQ*2Ve$~4dU;I#g?Sd3<K3w)v^7i;`y@~$TYtA#?IrDVq=G21P
z`iCyQ_}x|DL#vQ4ccXs0(4S_(_q~Rt$a_oOg}4lL7H}&d-T;joSCet2A61S@?8&Pu
z*$N|41TasE6WnvmPO@-jWJd|rc6b}MZ|^Bx*jr|b4#WagYj>`#3d9DYrn26Jr9IoX
zH+Va0h1|{v;SJ3U0Gs%^Oz!=dM0v_mU(s4KVZ-hP*2uz{E#~eWvu5q+Hn+@N7_lzc
zy<tL4Yel_frfe@GL%5iraX$UcG3l@K$AuJi2b&zSSwr2~fPzVXOhmnf3*fvG`i?+b
zj|UJ&VscW9!04m}{U91t2f`AiBjjy_#$aupm`56pXLyK;ZVVYif>TpClW55i97n2T
zS`xobWm2-QWYj_A$BVp>4Mch@3H;rGZ<12$3Fjq56zmfLB!}3DHAJBelF-}Kp#VKC
zR)AXcEK+?0M-Ylt;_B%S#Oh*G8e3+C6ay@I$wm7vc9fS_bkqi#>v_}UNXwkey%(9h
zZc@{xi|$QPPLDF$rj)bHW*cyZFIZQ_>#CJ6|8o1q3g5fQ<MaEuucEe!`;7N}KiD!%
zcMos*fzLQ98wGVt@?Msymd4%q*sOWRW}E;<KY(*s2^D|8C|^eMk}Iyfgrfw!@Db!|
z^wYd`?=^~Y&E5<?owvQFqdv_*a`g3ZcPwl%Q)~9~V`>RmiFGH#0a#<ytWCz^7XFb@
zCNNB<I5(FJshYwxbx6_n%})&pd~%Or$P#m<^qZHa)2X3L;WUzEr|(xX=)U92KJr14
zp(JH)Y{^HyWo%Io?b>xn71>fw@HIXOS8)%rlrQ&7>O3Hg$utVb>lC*VQ~hJuqK8L;
z>=MbY80<dqRv1+R9R`$F$^-pSNq{9mhz+uFY+Pl18{_`vXC~h&t_$NEF0Xs;j1o0a
zX-o0t{!==xcY9q+J649GC$FzEZoRBT^;g=4=JR&mu4=r0*O{v-{Kh-?%gVZ+f8XnO
z0Bo&rRF$dri>A!l5BTNqdv@C6skJS&tpj&ED$5l6YsMM;MzkkvRy68!P$>b77q`$@
zDer`YQ%Erg^SRIm$c~N!z>anUp9gi^h0`9D^_<kLCfE0%X)~xauzcF>P0ZtbUTy;I
z!j_<IT-muivwWwbWV$zOTi>p%K8>S(xhQ_>DC6@IiFvtaWl5B@A#UBdoNJQrhJpD)
zkSh@y8E*1~>GP;zAE5-Wm^DHhAx=i}+|`*#_MlrU!?_G^tcC^LI9+Q_a-M9~@HIn2
z9%W`V)ER0jrKEG|>PF5Rl|BfPVaSuTC`UpWqGDu>Vf05gw6N3@cdF;B8^IkO<f<KZ
z&3qck<l&w$+Pn_0=<HXf!5+&S1(Ak04!aYagi5)kL2wI{GG(9q75%)a_@KD55l!-r
z(NNnn7~K{f?5PD1Zg7{Q^0swvm>VnS_3m<Cov-|&QXl$mEo~qmNA`^l<-Ss%uQazB
zTYl%@fv)IaqNXM>814EY48(VKeO`&q+tB?Ep6D)j-%o%Wn?dd?-A4P{#9K%9kNnVA
zTV7rZx2BZ$x4<5n0r^_VLa2qfw1oP}5~BZIa4N-MsY4dHxjCQMI3PzbPMWliUuHdH
zi95Y^?Rh21K{Ge*vNza_L(_Skb!l(!Qofegmn@mwyTo_{AvN6id;QiauC@DCInaQa
zC8t|gpTW&%uMKYO%l*OaRw{bua6YG3Y|l5&=GWfd(WOqFqs*UTpOm*l(~uq2&;^rV
zFZ9zmxDPGTuRwYwk=q10Bakb91Z>mP1vEi`lW<c4J8m#sm)wDFC%2Nds6V8uJEnyK
z=Pq1$ZXh(Rqgs9b7VVbjdChaC`jC6o-c{~U{UUQ&Q)^S1nGath_wKs%o29ucOTVc|
zf0R%Td^l7VnmnP*JZ~-MYv-BECQJ^s-}1Av&6~@9_TAX*isa0h$%@&rKyy`9b3km3
zgX+*Fmkb@wYY4KpzpK8a&Ot=P6gC4f0E=nd9cZxJin298`dHnHz|!O`fZBt|0{SOy
zn1o}7%mP$_IqV0l3<FT(3>=4wa2vv6N<^&$tOvN-V2VbKKl$R_aj)5D3N}Ps*E?;F
zYVR(Sx4hiTOGmCm8eG@8N*q<*+;0BNG%q(+t+SX+mILKoz8g>5hwVP&m)BP)J{K%p
zY(J+&%&~3F#-Hdlrd{4@hs}AtE7A}&`OIE?&BsggSE?K(uIpV5k)ZmPGR<4H&N9bp
zIpFi%sPFUPod>S}oXe*un`v7?*aU)PNL|RRkadmHvL)zC%F9Z+N~n91A_Ym}i;%fg
z$|FrkELQFh_b9Jui%N1cN)~B>^;;o4R0fyI*7cAs2BZuB;X1<wpQk^~UCv6E#c9~N
zmTCLU;>Dk78|Zs{C&`eVkM~U%BBokl2KhAK_=?2}%P99NO`|MInVeDk5oy#0j6VP#
zC8rNGdBJr6RwV%<3?XzWGK+-bAs-2tQuH+|CuBotYk*vou&1A+uZL<QH5EOM_Bz8<
z*Y4=4sEO2?e|h-u&!?%69XRlq$~EK37PZXIkJ!u97M_NK0FqI-3Hlrz^;&h@d2J0j
zerm3D#;djZ4u|>h&sFnVx8Yg5;V3)9?r_-8pmIQ|<gs8(c%Y%JyAz!OKQswtmSiBR
z+Cy@%hoA!BmXM2qCQlda6*Yea;@uBFY>l@zf(@TN8QaOT$rTmhU)^u6@GrtHJRkh`
zypIZKynuW{L_<zbS?w~W(H{T4YxNX)uD;^~BfW3ka^p7X{Z&(+{p;QNypn8)q0h`l
zZ~2sYH6!Cvbc~dx*s>Zg<4?^#i9;De7xI&4BmbeU1&XquOKf?TPZ0%q(f;uTdBAgU
zvxF#{4|<HRh=L9&r>|Ujx+1n{1H$8vsL#SzTY_<bc6h5$wk8O*n6(~$4>FbE7!c-5
zl047w)Vje=<e!eyE{Q$2QU@^+geSTmK7>-vH(pj6EJ-U$XhxkYI%h>+i4tl_I-?7g
z)O~?3oxS|tE1gSbKei;%`o`+XC7aLdTQ>L$UfvX|v6eNI_c}**CqHtTwZeUwEu=*1
zmM(}olPw{|Hf6<}sH^t#iyxb_*m>o>%V#a)%OW$EP2IQIHhIk(zb|j_SZiZV<=iy4
zJdE8ac2$z=V7Bj}U)RFg=wKS8`f~J{f_Isuw=&YDd!i}|O0b2LljtdwG+4}#>`J5o
z&LY%;8pcS0uvD4=F-StB`KGUZUF$sUq?oOy!PI&`(7{m#cyNEKDYQQCl-HE~n>Ovg
zRM<`Y+q10B#-6&M-)r|ZRw$bdINM0f8~5_v3sdDeZ+S|o=O<k^81ApIPv4SITdR$K
z%$g`In2EEvrD~#CTGE^LD|j)?x|Z6>?Nue!^=4On)L0Ut22WL7cj1NC<z>bjz(;Px
z28My;q|X>-^as4waMY_7X{v%Z5;_jb&Ck=y+(croa_58oBL@QyU8-vI<|7$w;|Os6
z#o@!h;7^jji7UIh2o23mc6J;8eeb>8V_3kv_@6%f3zO*=yfy?kS9M7AeQbUJ5kRP*
zm2xx2LE*1ck*R)#OF&SZIX;{coXkY=HhyhihwI1zSI54u8Ad)cjYSz$_D`2Nm~LJD
z#cyrc@U1VdHs*@V7;no9jH3@2>b0s%)QSM?1Z@j4s9(SousV2wpd^|bdr=DyZ=f99
zh)s=m_1kBhei0W{VbVxaK5F8-H*9=LS>vr1z>#iqcP?sbYk%?_@%h72Af9@AG(slu
zm^=tcCowB2-#+w0^fgFp`nVh>BB7E9sGj22Z&>`3EvKxms=ap)KCj8l@C#2r<89^E
zPLWlCVXM8%Bi)JTckjGHK}Ka>&UtcDyotIAbPHs126E1UIMWazX=wWqCt$JYN)l8c
zk*t!;mQuPqp|E17rhL2D{)AUISL6RAQD0tFEAmzzc|m4YY#%76+p77K5Y@w!Z6H;F
zrzvlbi@epttF@M5?Vy*DyDm=z3ep<0kjn>c6Y+E<lCfkkQ4eVpi-K?!$`B3&)L}hb
zyvZQoNf7ry_jUC|0C6VqB)v*DbHCWZp=fL6tal~`8l9_GSHvg2GjaB8Y=V)|!<o)N
z+mtD74ZFj&(el#4nPv8x^4jq3hOsAYb-vp0hq9;2ZIKsD)zS;9`ol-r%*2B90+$P$
z>S<wKICPfBN*s=oKCQ2zt#@L2Vr_dx0}cx3;z^vV?g}>aEfVsDgu$l$W87n+U7r_q
zS1Edb2jm|bmYli-4j{Av+a~cSYKf3r(24;gtJ;GqAR{lIE>XDEXUmmYoVS#e+h!Xd
zM9e1Zt<JD@r%4@Lc3PY-TfgJ!-OxB1R2SF)EXZHa9kr^aoz}4PR;$Sz;bpUJ<t4W`
zEoC{IFZlG1^~OW-)0QdS8cdbtJD($_IQ$Fnp_JJfJ`6rA2OqW|8n+*LM*9?+1v|ua
z-3hXZirS4vaJ!=?Jo#|@0Oteka+_zfttU7)^#tdJM;>e+7|8ST^iO$i1hXms3Cu>;
z1?=c14$Me^n;M{>2b-kpNZ8M2+9%%<TuR0JyprGQXezZE%S);py4_S(<xsvzeI!Gh
z{BX4<_Qh6?O<$)hC{@E{xi34+CEqBi3aBNwNYO_lnfRYi^8KGOt5CfMVXaL<AH(bv
zOklwrlmw%CE~@|HY9BeTus?IBq8NYu^Pi2sDsW=|`M%$_wEXryxgDnmD8m8c)qo)0
z9SHF5363Alp8X?-*!GOe)?&O($}1-{Nfh;}Auf3x#n{4L88xDP#RpAdm{d6GIs@k7
z12xeJTLx@}uF#F3htUI{41bWZ5Hk1yZA8%z3PXL<xTj3`Yx&kdljH!8k%-MD_9~l*
zdpKa@uOV;&`#52L#)b`o84lR0j9GS^t-`*&Qq@MEEPe~Ut>{~mPW&|g(dfsF&e4xa
z|Ja|^%Tzb8P#<{EEu7+{)&S{*22SSPB>>$RnMYj+HxCR4szI<RZY7ZrwWy{wZ=VjO
zr}14IH$F8>D#N^nG0Mgqu7P&EFn{BziyKJ?o;uXj*gC&hdnrxDN1a8tfiX!x{!o2T
z?GhuY<RhaNLiLmN1*m6{(I;pxw=#))!U$TCtw%8{bYs{fEj8J+bj|a`1g9<HO4;ox
zSH$L=z#}cLCYw8DtFotT@UhzIjNL~^wv@ffXKyjyo<IQ(IKT;9XyK_AHy*_adkXJz
zxA5o$GT7*y_6m#xwHUu_!K3s%UPF)I1jSm=9n{nlcr@>`ybn6JTp$qAsZgkJ1IAFs
zWCvx|*G(e`;5%yvl|ZUnSYI_>hu3E{er~g&mH(2V1KuJJa7_&n^Bwu4y|lzc=c{dY
z`(Kn>#37y$hdE(O<oo{7AvL9@^1gYXvLp=;62R?g3X=&IM*sIAC1)R5#S}CXE8u|P
zya$cvGKxm_t_CYMl=rw36b2-IJuf-aDQV%RGzE|l?zHM7<e*pcx8*23Bqg-4y`E8D
zfWM*_wl3@wC@y>#|MR0M$PD%n;RB@98ASGQiFO4nnaJZ9{=+SG+>G<cG!6Np0BWkU
z%QYwI|J=zbs-;x_DSkiggvv_Yx9{D1ySubZR9|T3)=0->F_fA6THe=}7OqI1t=)R|
z8t=MY3hJ8AngXbzxxLDsLyB@}k0RwUUv$~I4L7}{sxLh$pCNZ4xiSR?2G+U~?LHA!
zJUmU*-|BfJ7V(o}o5o>dh%^Fg0|N_5pszj=>48F6fz7F$ejdx;Ron--lDWd=QG8n2
zHe~H6S;yPD_({eUZl5w)@wl##wk7#EsYE}Rha2V*E2qJzX|S@ADsz2*NLGy88cg$J
zq{3AeSiM(K_O1>D`WAD(xG!MT%+FH2W#<!Pr|tGMItjxc+4n#E|Gj*oi;HgC5A2qA
z0{n~RqcF2V(cDznf2Dkl)TQ&~a^=fP<s;jfbkB6mn4*!aZi-s*<;|Fpu_2+vCsL24
z!J!`5Ies<l3Hv0&N*?VhK9Dg)a101*P&)pg4ah;xGntH{<uV!6H?+XFWTKE5kd1_$
zoY+%_H7%#XM^3NNsCmYK1H3@UR5a2MAA+uDKKOwOF+pjS(Gxk^@)Uikh5$H6d57U2
z3jx85_&-GPBH=v#M+t#j_%I%jUVCE*yZJG=q2o`{%jvz859Jn`UDp(g!qTzv1WZpW
zzxgNRETh38NaRZUQ$GQVVTep&Bvk1!`D{}84zxdw`chB^06u&X0I>uFf$Q-q<;amE
zq-BpBaY0Ov?l&s;9yvn6h`4|c7475Q3VlEw#EfI|-ZP4UBmpcGO40_=96Rm|f5BE^
z;|pvy<1t%>*T#E|mu(*&e@c0`q{O)2R%J8pFY)35jcc|F`w7k!<)I9?o$`Pj*aSoo
z%Hg;ZiibXIyv%!Tcr&tLQx+U|=3V-#kufs1N>2%2Wv{gHRmYu@b*tQf@*|4bMggI4
z^CCJFje|jII4tmTly&rT%DX63rM<XNdA>nCo}+Y&_bAjv^robEjjD-!RCtjOREWrj
zGBKb365~<viAsb{=i6HOsb~*Gv6O>LWGE`~#G?&(URF5OXub)QckTIt7JXpkUC2`>
zbgnF$A2~wBb|!oBulrBq0h`T+s-f1Y#4e6&lxE}0_6j@4r<NKIV@n68+Nhn5en&M4
zSwQBz)I~r~2q_gP3Mr&^A>N|cj^-Jk3;ogS9vTwc9oN}Rmu%Z{TflSL*JvzEcmb82
z{Bgd+<wJi-6De11-*I!P<LqtQ?l!g-Zh^ay%0_Z`7-KHdhNlrbXg{efkOk}rWeDu<
zBOO92J+DD<o|L*cQdrTqmrF0A*!~1+T7<PC8;HQ*8Q8Jdg^wtoQRHF<thvG?##1S&
z`cqW8l(c$jKksWu!!K6~3EG<XV~YS?5dr{lL?dGYWRM#OI@??f;{ikn1o*>2xZRO=
zgvAciHx!Mw1VZG8a~YBHc{mMOmZ{!1Yu0McQz;CRowecQuQpMbn~|o6D56E90;c7o
z9O><&NSw!}Yfks}Ia;(Tz42gq+3Gb^-%<}yeamu4OEn*A{HaPpV~|3b`>6V<FrTI%
zugMyx0ab&kh*tcRr5Ms=(}J(jPU-ZnL;1d?E>y#?`oO(}J9>bsA<ofFh1|m^oif!^
z<@r@fmB+?QOR7qEsm)VqQy$5E-Rbc-m6QI904@)qqYMl2Eam5P<yrK5ipi160SY7U
zE%FIrCdH}Bj8jAdJS`f@MlZ;T*9h%~l8Hv;Kc{cxhH0CZjp5Pv7c>~L5MoHwf~27i
z&14w1cukF0yk7~?SH^$8)GeFweyWo&h5L13xJyL8OBn$fr2HlK4a&H(;+W!d(tQ6(
zU*Px-)yrs}(|D(Y>I7PsOngx@q+DnyR8~2}{BY68cr~}v^Tselj>xunJ-!e!K0Njt
z$oJ9T!w2&b<+}-?HN=WSGmeT~s}edv_7FBsWyxLsk=<YAFkZ7`ISWUXA84-7mUqau
zY?75hfjaW_Lf4WMCfS}6EtxsCB}s6hi_v+vkxEi|6j>{U_lK3j`$y`9P(3(CEwCaf
z;Bnh>%okdUKLCHy(F|5$S}0=DrDp@<W+WM-ZY9_iwMM<kL}L)a(7`4+Ui=;9XqBFh
zZVai7wh$yaE<mQoIWA%7pP#kulyB^-4L#-i_kqc0p2fqLpL$nH?KE5=H4_RQ(LzHq
zMovhV2ZyQTwBhOVUg@_i{mPRY2KFE1HQTpVFS4Hgt>@QjA^eF#keG)<0TBn%_bK(u
zqiQ!IT8VGmh%ZSbF+U?|@gca)VuA}Zh9X$JAI$ISyZJ{i%sJ=R_x9J=zOjc#&zoo*
zx#ynWmN~w6c&kl0wCJTDUOP!!QqzC$ug@tluG?wbdiZ;evftiw4>wIb&v;AfkE81A
zLY{?brhE&RL4y5c{X^t7g$wf&D1sE{RB~WJbXnjdry5jN8-G6i2D>SJdjGrWOD`|4
zdI8}iWxMb2d(YT@TBfTkvLvOJoOwoX-L(C;Us_)ssn~qphKW@Z`Daf*{Y&nBo15F-
zy7G!Urt>Ry?tG*%ykv8|$$s{drCa%e$v1v+C0{jPElc*RGxnU>QsA4{)c|B~7~CHI
zgq(i5oN1qslfrj1stJ8;0{Do0cru`t93)|cb&yEME)*2$E(|FN4W;}h><sR>d{e0{
zHgB25e)<BwXTpNM5MR@~;VebH<s9SC)@$9iU7P*;SFbs!RF#CL4VEk$h}pd7ESSGh
z3H2?QFmLy=ibJWhZ&j5`w`{$&a^)Eo^ZMeLXY$)MVw8(wP1e9ieiqHGE)L)<MgVZ#
zkK-eQ)F)?(aI?sL2q*HnGzlwPAe)Bhm;+oSf0%TXjX4+>OzaXeqTo6+-hgK%PL7l|
zo6-!S*@iA`i`#PBN5jxn7&IF7BW!Y4*lcYi{o=MN#c!j;M<+7lKoRAsqGyZVqg;ab
zkoAif<lY^9&wD4x9SN1Oxg-5gK2!7;TFr;n?ZK#tkK{=xh_ST@&E#o7w+UK{1&g`(
zU`6x#edqIv^Y^W9p4l>Y`;HG!+djADrh*XREW|URkfDV*yO!HluUxs>`0&~b-&?$J
z;o|o$)I#`6(+1f<Y-&C)w9-(8@Q4o33J%FJa;!+e<<TJd^uVjUm?6wXbG*z&{D6E>
z3ml<#c)yUfl<<&X@ayn}2;8PI8xr^YF1vk@oMwae$|Jvmk7|&d1cUYrU4)RMY-bQ7
zj)!Hqx1s?g(%Z4Qcl3Vy=xavmn?Z+?&$p9C28zoky*<KB`M)XB^=03`=9=%9O?TgZ
z)>*eh(0+VL^5Qym^&_hK$kltUQ<UrWK)HTWA<#@hC&7O(h?QO@AlhgUD`adOBTL9z
zMc%hWRLBuThY`*df&^k_Oj|)UjrQd650u|UTov2Xo677h)0c8(%L0CA;psN>>My9D
z-)ZdWSToqjcdrdxvU=6Q(8*`#8f4L5J5i!+ur&0|ELk$pY;|p#v0#g$q!unb`x^kG
zje~33*In*ee!9hc(vtFn#wnwf@$q}86Vh`}iFkx&NHEsq2U`{u8NJ9#Re<q2gc?YJ
zOO+(OznEhJ=QRbr&@m5fzC;-*&8D*z(?zM-YZbnFc5xN(buW0mC24!3&vxqPXwO<%
zVX>V08?EHCn@0<A{pQpG_jK3xIdgZE78iTY=DEDSY`82`_AsSRi>uQ4Fm*v0BVK6R
zICwOHxDoJak*4^B)<v*$5@gM%wEwTQ-YH_rPpYu}7aB~{v=C@WqrR3O4`z@jWtd3=
zuduJHqZ1fA($N5P2H$W@^~4YmN7H2}Fv{%d@;8A?F@TOCMbX7+CKcu%DW^r-Id6~V
zZq|5ai>s{6wMErJM>3dfp@odEI^TC1f8%A|s#LEj5j_-5n0k5Wtrkpra2>+H0y%tS
zy=suPQqMI((@iG7+o#fSRyc(L;4nuH@XZHLT%;%Lhk@SHMS)waV6swBuN<8h+j}JY
zdr8QqLqi$ZO-ES<ev5RGnLAU4M~N+e8^$~yqRmhUf1IV$nT#Q}RF71rg)-E`bJ&hD
z%z%_$i8X1q(fTdpmgbl|!;47pxZI)^c4B0@(K(E3$(<$(BEA!yXdJPFQPUXoItED4
zkHij!cE&Ax^Jr<QL<Hbva-9_5T$8DhbwkL*#m0XDN178#`CTWxrqGt}kv=Ey-FUU7
zOdG8nF`|JtA{IvIhs>C7E-bu~ynyt|#*vSb_Qpr^GBsVqA93n+Bt)fx6T)J|145w-
zR;s92vOD36)NwK)8!SFBM&6Gr(7(cp*dZS>AwwGoqKg0zH%2_fgsV+jike>fZHt&<
zW>JWPa<Q}n$QU@v@-x5gO6OHcL4G{1KCR9Yd|t<zAU|f(%1YyLMiU|&FhtN~B})wP
zw3Kg}F)O23{7s0Z30le2!NfL1Zcz-Ab=+bZm-V*W8{v}sgG`OTQsaxEOtj6qddG_0
z9g}MMCOTYkb!NR|`I&UoYIljq7ZK-o;IZQJ&J3+6tuI?4w)`4uT44scOFDM1aMVw7
z*<1T+CKc_E>PG74eymP5K}b*r!A>Ye%vs=!Gy&SP9CbpihO<&z;2c7UeTcI5hkY#o
z;A%*slIuVJ`Rk2gV@N_3V@TF~wH(wDTi&=0H)M?AZOWOspDk3L<HHh{ob>yvul~I;
znQOxmtqf=K?L9p|hC(AL&mLu(##yG2|IOe->hBehn_?_ITnze7To{VT9I-z62=Y@-
zTm)Qaq0mtf{>IHKXLK1^$;<Q2MfsxMG3Jvetl|Pg6-J~;v|ubs*yj9`GMN!QB87Gz
z*`fS=Om>TJ70*!KC0;7d`$V9oPm}u-GRP-pdk7JAnBUY=6sYsD*-q!DJ54X1n1*~)
z+)_rRd>456U*Y5Nc=!0sja#4NWK8C3R8|r?8AndWMx1tV$B0l#^f7sFkLYbM{2^37
z$<EumW(QXvIe739mG4-CSwm7JD%XtbJf2pV5JlqjbJI&Z0`&umGEg7rD4p(E)+1Y{
zXPNX_WJG+Ej9VF3;!^~XB`Ptr!iwV44j09(Rm}hTeI-tH{@wSNIE%#gU{T1LlD_}$
z`Kq(zzPlHwQ0T%26yGNwFwOaftfp_}C!r*1^!!+F_Q!Q96^Nt>HXDI@@(+?6&*Y=R
z(=yGamHgb3oJ{#mBmHY+Cxd|Pp&}=gk}izQ7e?zxCb~e7@l0Z#K@)67o)<fu(4bfx
zKy1L%z+&L(JHXQ=@HfyrvsOfC(JCTn3{HgxcX+4(eu@!5;lViOE}DLujCeXRaiBYD
zj$rO?FxKU1z~s|tg66kd@j$^JT5Qo_@d`1zV5G=>C}EeE|Mv!4L!f5=Gn=MQG5d07
zZ?uPlI7(?#%$VM?OVfrGMrzBT?R8Qiiy0IuJDMNe;0(nwh9&SLn>DjH9B}^4$Y@jU
za+Pbv2Sz%R*UtqtE&I<sr~{e@I-Axy43pOf$N`R$e$4I}83d*!F32vK2(lK>3F|_v
zO1`!*7YXi@=tOP?_P_!ch|TB2OV06!L!I_EOtJ`hPm<o#lf0n-2K#NDp|Jm)C23K_
z3~hPJNQ-)!QEB(6aizp^lEF^0lqhl4V}H)EZBbMGE#637S&D8>mDNSOx70-!ZF4+F
z8W}@4#4GtZRZhn(7JDXRx7^|YcNb)@CS+I;`Z$7>Rk}Y0S7C~DS7$Kk4+LoqheVPB
zc&%{AgAgJ*4$z*P4q^6z*3=R+u0A)`()48|fp~=}_uqH_6va0V)>fyzTvGD#uUvk^
z@Vj_Tpw#$GD#b4<#ezv=Z3S8bAdZ%T*LPer*agcaf^|tEJ-+`{0qzmD*3kc=82oL5
z4glM!af`!fTyIpy)qPAkutrZbiFzJKyw&w-y%#p;1B8Wq8zb3XH~z7E@$9v`solvQ
z&xo~jG?oJT>_WGe&=}k;AaYR(-VcxPchSIS9*klDv_3x?Dn~r%QIZUJXJMR=4+E6R
z0fdxfQ@Mq(BA|TYD?@_&<p@zGCD9ybG6hVl4v|31Q%K6rV^3-MPECP5Bm^-tQO^ED
z9Z)}i20n@D0*lj{9xNFx6W+Ztt0fc*gp!G}STtm{lp#h-tSFE~O7n%eT&gD;gAXu{
zg$2HU#T5tnwu9Ijw{AF(cb>Or&w0jc=k4KVMd~I7`n7u&TdkX3S~xFc+_Nz=Z%(A8
zA=F$sb=u)YmYE~Z9lYXUJXLsB>9txH-=p;hCe}sTLh}~BWc;)xGG|_9qs6l5@U*Fw
z&0*3_dA>0tl7YrPPC`p^OC6R+P?OxPTARou;Bo{c0Om1~!AQ<O8E6Naf*iqx1ZE_y
z3PsU4$&my|Fv#yT{_gb8I``1P-c7Uo#<SYlNR+3X{=swR8ojIAZ@iuF*toj$#@mfs
z`L-a%V+7jB9T;zGqY#@mBsgjdQYgl;S13rMlH2F(8}!%tXKmU$zz6)ZH=fnMch0$k
zepjx213&fl8@pF;G;Y29#!h8!fbI{rk>`<Kix;-F#l=y8K6UIB%1hoZ&y2{pCNW!t
zW&zHEZN7}woh_ctLq;UH7YvU9kOW%X8f~&zv4lpzoD3v`7AQ5GG>cPMfdPXfbe&E%
zC2+4bhBL8*S)3U+YW~iOU{!N?lCrCzGd!U>Xx!<DL>%#5t+7yTm^Z{)9pPFdh-)p?
z70Lyb71b@_PUHS~9CyvfrBIBY6sik5TZ@D3`-%{avZo>tk4%nDNlxf(>hQldU@{F@
ze6B#R$59h-4LCf;$#i8BOsn$trrrtrEf(C;i3_~}x@iDw;0ZY^`-c@CBEkZ3AfPz~
zTh$sM^$+)VKRN)dG~vd;h{`d9Bu=A_&^a)ugxH7x?g_?KA39Z`gQ<?LK1=WZzO^3D
zK-+@)&i0f4y{&y!uyXEe+e&&joNN4X)prJ_UNx^ewEfzBRo3wAOS!Ag6ltuV@x@tm
z{S9<9>TcBOc6MAkyTMw0?i2G4Ox3=#$`p!h`_Y`L;Hvi4>H9YH@*6wq=g0d!E$hD*
zUDJ1Iucc2L{Nl{|NW@e><J=9sxkW`?SIWh=fcM&A+ff`X!e=Sc2&<@I?KBLEAqJ_O
zm_RI`Mx$c`m_vo=J}lOO5PS3|M5!w5KTH-r@o`l#9o?&xD0`2Z6!q~|i>b~R3zw?V
zp;DWxuKb-Xwua^g+m?6A>z%gJp{QCKj`?1^;e2i~8IP(~-lUsMdJ`5DdektPc&}Zp
zXexjEEACjVQVspUS6=S>K}fBP#oS+cyS%AFwVhUsm4om-L7Iu>ZqOJch{=Ihz^nvT
z3i~I*uPVYArIbV}<tojf=3=5_0bU|Cv@s%;&(rcUngp#TZjetaB&|i*XyNH&!712H
z$m3xVhvo(niIbn0=6H)(8}jA!czhiZF>Fk0ivdqsc}JHBX_$tk!(g~3eNa22_Nzzg
zj|?;fP%gsZ2_7jxhWXu$I6;@y5P5sVA!i6!WprnT96%~uf+GjGQn>9X@+<ZBBakcf
z3lCA?V^lH?6Qa7o`Y@oZsA$xGG|@E1vzpqyrMmu!OD=h$zIw~<s}EF%fBmH|4Tq`^
za7PG#%JA&IH!ry0i;D8a3odxGZ}zmKH{SRiRsGHlHy!<I$jHFH)<SukxggVMT(F-;
zgT_Tb#iIzm;)5|GePa&Bg&8uGqu?+!c8|u<&>)YjR4jZ!)5kD34BKK9qOKpkC6^i9
zA3`mUdLi@pgvux4<H+EU_Cr2yz&s`}5mymQNUw0`H1Q(jWF%w~IZBInE7*gfBW`gk
zLv)V#Zd8wwjoBvFei^qW$&yAB3w{)GPPiH2BB#L#hyxG+aiAYZZXOu74h@>41?MC3
zkhgk((=V(k4Qqpqj;_XOv>MZ8t{|pPgT|>!{ypivq(VvLjlFGwnFCe9$#dG2Cp{(A
zUgIs3ctut4*mBbEk6^aY$8bzOoCiHlEp{Z0Tc&qJ&3nBe&V=h~oNO!KD>nY6i!0xk
z3jEwWe!fP6i)hT6#xBRr&O{nPrjRB35Do5rZoKR)r>hizGei#@EcEL_ULxq%BisjP
zauGTRf&nBuV4;%~C(e`MxDOUMpZLGX|G)*$!PS}?Co_4U(&)Eoo=(x%gF^hth9NN{
zIS~9fUQY7v{Lm+4C{vXAACH$eS_5y?P9eW%H#`IV@L!M1Ma5XEIFCNeLW@F|#uAvs
zmkdC~1})<@<kRCsEGl=j4f%XSZFf}v&&b|5dH?4)kpukJv5$)vkxVFz;gTQ31L;n4
zyYh1;VM$_=ftWS{@lN3}F!lTS82_%hizZJfWZ9&7<L~i%K_YOH(|AKJtv+VF98xs|
z5@kXhD-mLVLDircHLbcaH=S#Fw*`6KjWkamcr15C2!Cm~wJC4XKS4ZFhCB(&m*&9$
zfnvsdVNsyG4M(aU+3gGjVWrVx2c-IgB6m%e1`Hq}umgJPPa6LbuZ{;QQpCeYK1@=Q
z!s0)aH{Q7>5>I^T=Cv5@qhsS|G#+GAG9rOvT+<ShnpU(G26wo2?)_C3WVYJk=xB;h
zZJd%`X8hNEIH3`GS6-kFhjK{$si7P$)bA{CdLEY`&jd@TW~Bl}DMU<bev$w#2z!;h
z)N<91kzo-;sVjL4QDTuI-syzLAMzHH&NR$cBgu<Z=%}DVEeOzaH2*AFnbW~DqyD7j
zJC8g^F%ThoAS7qI$$h+)lnwr)Oj4JmP^JKD1Xf<fHltjq_a15qWZYkv;DJ>M1GGp?
z(q9f<iCL2Yx!UCk3k8cBV_Bnfmx0?cEHu)5{3~t23d!OGtf=Hm>IQo1w61lh#!rnW
zwG$Osi?3YK6h*e{s_rOmP~ML99U?sMNo&=1%8ES7;`5x=#@^6m?Sk&L-Ib@t8&H$S
zR)JPMvt>&{o}IX$?(4-3PCkdX)qB)tv^?B6K65n}b9gY>3sx%W&zSlTKG6JLuGLYh
zI_#?5=5+%Nzhd$s0-%HcdZO`)vc&iuFAaKO$(l@VukFZ3c86N(;Azx7{6b)=j4~|Z
zb?JAt=ohqR;7gwrvs;*PIwp3<U##?osM#fc!1N0lcwAU^OqnOgn~e1#&g7s?J_W{*
zUP{2ud;l_&0mur{+o?0*|9<ps8iNjnMBmPr!3I<*=JSi?mPk#*?V9frVN@0P$*hu;
ziK#UEnVgL1oz)7Y1ka&{33<;;NDp$O8u_qh!O8_SAFld|x3jm?`x9lz5O?4KrV%*L
zGd_u|0ps`e4Gs0Y`cM8q!2c&93*QDV$Imwbm)FC3Zl&MHfy~Kw)G&G}Rxyr__4pK5
zUr+JD&|bwY?Sfne>46){B9v7{lZ<8vgj+zml+L16R`^i<nc;m-Pq}hQ{^k^=RCl2a
zZHNV^502(nj>02?uqd;h<Z-!`W9sD=Oxd9xSZ;xDUhZ)3$7gpqP+QwtTYH~OjMHU0
zRGj4(s*lfe8$C4ke?IT8!~P6G>og*MU@HBR4^4R?frnN{O_yUX8L~fs{*oUk#mR=4
z58|M^Um8YTMf>5R{fDlqSe_bz0*#Tt$-Zjql;&2`N6$b1kqO&fr~JqrbmKn?ao@<E
zar($^bDLv=`QHv5`ZqJS-P_t~U$oTvzR+`lcGur5mcQMFZ9AXls;sJXp=`&dD4&!8
z1z!5ZR{8j<|D-y`_ehVA)+Zre5mol-YP6VXlf?(0q8>{_Fw+DR7lgv6sk(OJ;S6o^
zbv2szPs0V#*h7~Gmv6iO$Ey0q0}s5RHiiT1(89gV6O=!`@x~t&<^9*sKe@Tp*=&0G
zh8tfpnO?fFcSmdOvuMI=doG?a(u6!N*4iBwwm6M@(U?FFqH$=Ivo&nXCy#&uB_BU>
zKc)&#c*mFs?P1y96|EpE3IZaR5q<w>QtB3u^4kk_4Y@+HwfLGbzi?3*M&A^o2dQ{H
zDb4&_g%~oP&5P#ZAb{JcSuju~h4yh*7~wL=ZbNxl;2ANFLWl-3J8xtO`HeFqzJX{u
zQAu*OTHGQ|5kP<`F@GnmKe&_cl&Nt8-+A?SO{VW&ZQLN!x{f;hj{|h}#}{o^l<kYe
zR@slI<TKdL!vpg6tG{b3sqLE5g?Zlbc>nyu?Re;4%`?`u`(Ngne^}R9Tif~nmrTpP
zL3$E8DNObU_eW5#1aYaVv0A)X+JbyuRM#XX)gA#EaYWh*i~IyB1d*<gQl&eUwB#%N
zA^`ZSp$=<J0a6tP&b0hRgVKh&DYNJz&(F9a9Xc<O7HpYFN2YjZ`%_w4LQhIC26XK8
zQ~HapqbP!R_Tx^PlNdvZVo5CDN$Ymd4knn9_Ys~XTEhcgrT}()K^{|Hjj!)@#XGO*
zTi>>BNoLk`*Mi6L>AKM?ZN{J4cxl`EzH2(;uHN<Wbqie6XJwW=mQUA>T;WscR^-J!
zV(ZENPb<pND%-0N$+{iCK6U}SoPIxx`1|8LiAe(J5gINn?7*0nXAt5x4MW31<H{W|
z8ct_~56=$^1`lF|bnL+Fl{;dIhaSAGhung+14oeD;dw#9(^6oN=_Fgkkm=t6!MqRB
z#z5q2e)kLF7&C5h_Q!Azr3vVbZq;=0k=&O<?fI{WuTksfKO)wRyzKagj-22{H0J%W
zIVz0PWz?ClkSLCW>Kk?dSU?OU(*nD45x6*4;T(XaCsv39L;zQl^@K{5>yXeiI)&9V
z@^4g5t9Dx)_(_gZg;n}rt8e0sk5+`I&6*Z=I$mq(ph^1%tMBnXhUKX%e66Qq_C5*B
ze;zDXyw1kb4_q!j-QQs8w|9i^sctk@`gl4rwXtz(<Q|7d?RU+XXgq@!sIK^vcVb(U
zv(_8}KS_I9Q)PT-1+b0qKJgJ$gY2R|;t)Gv^kRSMnDF{>2bu37@jO(}x;$@j3%k2G
zhI5(-s^Sc{;!lim{9Qe$+C?;1C2~b^=ZV4?2jMLm-7LzQuQ=AUk9Tw;k^>R5h~rRW
z__^0ZoA#^f{!J;dbQeGWc2&LoJh45!<~KF?k4;U@-<o+-^CV?|YUJk0h1=+658Weg
zH}0of8N3kWdwCZ1M{ADyAtRd6BL_rO21d3$=ruAbunSSIF~q(45a|d{1@#-mYM8CT
zF<~e9MK-`5J>eu~z@ZGZ>KWn`FtIwSq~R|ot5{2Cw2YRP2gZgubCgK#HDx(<k=%oB
z`<9ZDEq3LxIk~r$h&%U?Fp-v(+P0RIY_;)F#uyr+Q$y+WP$t7uNW|gNr9*h(u-og{
zgBjF&97fqOCrA{I`d<p`ztGB1m_;ru9<&Lq@QUA7fuzR)h5~%hq*&7oPBHj8Fz`VV
z9)OuvxGdZ#?~s?sU{Z@(RrzRr!_w7D8`gL4pEc*wRO_VniR-IsYO3PmxC<B8E(sg`
z;(T>=O%<Q*boeVG;VP|R;=&r*s~ng+u%*b?w|;#!{?_A(wfT31m#kY7=Fg41W9++M
zcQ`8t8u79~kX}Z6{)!6Y4@I#_s28axGT?*!YC~9>l4JsUjWBS!M4v~&$lWkr?@|ya
zlLuFn3hBk9s2taMqUOy+`5ea^iXNbKFND{Dw&XV{cwL0A>Ztl%brQx8L+D-P&-6nF
zjQ&nW6zd@&vlw&sla|BKvnL!3C(W>FsYv*l7shWY^_dp>w$3o_oUzrn(Bv!St9f0S
zTm1umY&rY+&l@*-m2Y`-i))=TTlvP;na*0Cn^sk7{B>Xf=L-h7v$TrO#~c7FS1+~4
zN+L6s7!PP#ddq}P%(a-XCC_iD+^3X`u)65iJqGD_Pz&pipl?8JLE8j<^kWzSa3P#8
zk$ft64oj244;b#kDj#(y1XhD6U|@4$=d!TqBAVNK7k(roq4?Y<n<8_T&xx2Wmua=X
ze}2FJ;p#TbV7_OO@6J$SM(O9u8rs?h+uDrFWjcg-AaowM=SrQ%P-$SoytcM^6U6rP
zx`u|jn1}B$Qt9cY&|R1bJ;dqR!8V>xsjL)lFR>GicaSV5J980Y)Hjcndt)VXQTQrK
zguW$(WARP!Mo58!YNBOsRI$btNg&KQqhE0dl8DT6j^v*Lr_g-(g76-f361N71c91K
zsqV^vX<hDr=tSC4rfQaoCiCP6CU9*6@w0sSSY~{OB>vDi7nP^s33EA&v@2k7y7-O^
z4xKcesl<EVo9#T*F7Q2Ma7tYdtx?Gu#EQpga+(B|G>gg46#jw%s$^55bENf)>t|Te
zKJ>NvF662c5;X1hI=R}kw)J>pMVXO%TAA@mX=&$A?WH*^1P<rZS*7-$)*7dt!2UpU
z7W|HsH-{Lj$NbKu$XqG+Am&j0mvZ=_qW$O-GnwI+BZsdT{pqyw^ry_Hw2?@D`B8^0
z(6tQs5dB6hyD4f<tdHh5qh@K;kH$Lk)tZ1BmM$46D2zR);ao#sqA^{pt_jh2WLOo)
zVg*ZOx2#qrHI&mrbeoFXpm@-?Fa|7t<VdV30H7#sI@BZbmtPAd2w5ef9ejxAfGh&b
zOAkB5EqEV_Wt{Wl#gg1vSI<o^+<fy3CSG4(-(Y_4p@-fxN9s>_mS2Kv@(yhAh<woW
z0`KdqYtS|-%0{iBuCJ;;vgIO0xoAtI|E+P(Uxuslo-G&k<0<*9wo&p-UJisIndb>?
zPtw@~Kmkcdza>JokI+uH{Oup<fyPin*mvA{8ka@%Cmc{{H1TXPXaca4{s0Wqrqegu
z9i|eKf<Lp><gn+K7mhUwebtUTEpJmE#BEkHR-j84MQ#tah+uBcGCWgw((o2uD4+Z}
z&wyR<<4I#r^Y_Ob8=H#ayZiGo->4?)TU1ZVZ-BbyV3d6c{jxR6gFqla3ZN%ZL#QfQ
zZd8D*iJnhWOIU4N{0m~MrcD-Wsja3y_i}xWtrYQE`D3Lwf2`R`JoTj$Cgfh8Frl>G
zQ)0^>E4>pYxI;KTl2+4_(xju6sbgPc92k4d&o>U_pUl6BpFif!Q4NYxE65k>w`TO)
zTGRw-aw~~H^ioLoqJ9b#Nef$HHD^yO66>N`AZ&`?{+RZZ%APWI@;giIua(G^W=meP
z4^x&g4%S)InTMJxdo%Z@()_08c+cj{Js|q{N~i5T2MlQ}y~BH7){e6L>O-k~%rfQ8
z3eUfc#h|`BC?a<~fVjwJ3&#ez0{7+x%9ufIAdM3^tWI>24;#Zg1WJi5_KXxwUZ$u5
zEu?C4R4;YBOh0)1DGpG@Xh9Ql5TtV{QP3f1sn7|e{J;s$5gvq2N~7#5W+^wpGj{^H
zX{P99h%GU4Nd}fbgP%J{6^1kLRsOzIi}`SDT8m22NJyqdmaxraq)N?Xq{hw^^dWuQ
z18ZawMj|0Sgf$)}n|&IOP+lN6@kI!fA`XF3LVg+i-WFO!$zVVYVE~BIP|^_20zD4V
zcV(c)Fqi1^S^9|vMN8qnG|-R8q0CEhK1D7KyT%H#{QJlsjp4S?_$_3!)F2}}Bao$@
z#@Pv$lD3Jd^gDC>;rv>ntuoD>Ou51@0xAv4rc$4pQanyW!{J4b@@tmo{ymgm1dr!a
zwH_eC?Yz69)X*Gmw^P}MU!zFpb<7iJx*$+|Bjw9C@@)dg#GoEAfQzAJ--y;5$#Y6g
z;_G`<k0bXB1@(dQrzm+)ai<JrY@StCNndx-5Tyu;(a0~7J2tDRE=D%juv%PS?PLj$
ze0F5#<LaylBbgMI@h?{%Kl9|1C;F!P9x-~4&D@FWQQN)y=0M=*BHu!f$v!}JO6#_f
zCOdAXg&A?+9LoyFXifrBQb)&h1P#!FWFGx-EuEK0d2<*Hn#p9+6#XT}8;^b<=7zWs
zAy5ZI7{z?HIKGSpiD?Z(n%mJ84tDj>PdLg6A4xLL;kWY@XX5e4d$kH`MqaV>{JCGE
zJ!8j5>D%IlJ6=|l7j(!BdgA$WWtvXEPIsscMR}s}6_N)@a9Kb~18&9`KURn->VN3|
zm<x9ePuT)Co+2!&VZ!5YjXM5Y?)V*VvRaK_0(bs5<wWt6=F6H<FG63E^Myl<Rzslp
zA*j2NX>AT3RtdC`l|<c$RyO2t<&3yUB2?#E2>(ME*K=)AQd^8g{8jwM=2<F!rerDD
z`YhGM_mT)4!enLqVFe)#s@?4{SZPHCTviSYsnT$1I%K3rY?;xvr~cF-=B%7h7Lvpf
zzqlx-bU-Kyc}Pn$5l_$+`ZcOyNWdW_P30FNaIa8NnCA<5EhQy+Z)sM>Zp3fkd^)W%
z8W<*-$>=cxn>mIvsob!Z7o8AoX*~B_iic9E6pgW*fN$xSvnGi(vrZt%2_+Q%HRbb4
z2m-%{<cXo;i`qB=Q6s0-<YDq|0t6#OSx)D`Do4_amVu0=OdqA~2?~-ye@Md<M7*fT
z_9!0MMllFCXGCAHL8>*N42%oM1U!@ps$$eMsRL*OOcf}YkFbsMfPRJXM^q?Jw>=_5
zD~4$0t6iK8P2Zp>8>Ua+aGTVgD(MG^{?On#JLKF?1I&zo8Dn@@xnwg|22d_}wg|gP
zzgZf5Yt7rbBV&Nv6T!dZtqgOqgU>(i0Z(bEV}(bY#_q=cM{P1mU=3lIxK$0EIE25^
z4Q#ezJua>CLQcgxA1Bo7)SJf5;P_cbjg1j{iW`ec4#_@s{PqO7*K$l5&HU(|;Dcj$
z`h>kO37#cFuNh;sEmkr)R4W4vIG)Xj1Ho!c`Ua^@Em)VO2H2yG;t^o&635Z>GT9BR
z;5JOv11u*+P#6rtIe#)p>)wjF4~SZz>d25EEU!x;pXNoOMaz|?xrdcQ<olKjjAv3Q
z^dOB#%69HV%)*e8!ZlzK0&Y{3Tk#uKVbc8Ij3IK9(Sqd4=6^0)^iR#WqTGX(<q^1m
z{7ZY{S?GWIUEEUerRXiPMzAbIxTi}ELx+TWz@9uz(zK{wCw&PXB7-JHeL0r{fV|&0
zSy`UJL^d?l4gAe=+oSj;F5)%yQJcb0YZ(YB=>2pW7D0x4j9aWF2CaVJ=4;^;+wKv0
zg6<zuL&%d{?FO^JPQ-IT^2}oqEvymI0gpEXJitF9ujwaN;X1ZhhtPx}X=)2e_!^{!
zc2(z_6-(+baa7vg?0mCwN&Ur+$`brGmv)T6%+S5LM`FCH-X1))M748wv+?Kpl8UV*
zs)On+FJlURE3`U|h)C-kfKtjC?DPY17Uffnp;12N=$XjpxZ#GR{x##~_!CuwbqAp!
z|1sfzs;AdnZ427+KSObRQ1dUvBewW=D=q$0tcrEGeK=q$#~#FwzzO+2VMD|o{cdeR
z!V4WlOzS5HZkNXg7Um=!(XWq*^+^gegS!fR42_jm88K&C`eXrD(!h2Z4ToUpW9E?l
zT}pm=Yu^2<FuIp`SVs2K<3tFy(KzWj@jGcqDa?O5%7`oZCy?a%ID>vz_{~SmIl%0E
z)c;7Cgj4!oFN=&~(*74^_>_4^=PTlO%J&I0K@bBgVN4y&D_W6~#=y{um`q3$3Xf-Z
z7<EJ9PlHtlw1}mBPr&IzaLuNMgkHrChDyXPdLRDOTU4@Q8-ztg3bLsC3-6dP5W*C^
z8OQI<%EfE3AUa}#Xt<+O#v|s<Cpoo_w4`xU4<DpiQpWfA?KL&V|I^sF0LE2ZS-$&T
z|I{C~TAyx9wxyP2N!ZY`C0myNJb?|w=93U?6B~$HttY8b|I!}|C4AaLGC&9nOqc{_
zL+E5!Z&)%6li-jHOwxf6l9@>$As>b;i2`hfoh7?TJd=Tm`+3i;dM$O^a@dyob=7<I
z>T~PfTeoiAx^>*;i8t{fyDqwP^<~yx;u5n3F*Ahyd=a+nw1>A>nNQP)R$Z|g@2Gw?
z)D-tP=~DQs1Dj@BD_xAQ!h5g~{ueh0gVK7qV!TV3ly9xPVCA<e3o6Dx@X)tGTW^Dn
zvcR$D!M3&s)u(ufd>x$f+fL`V!7H`Z!bzT_`vrhzwbCv2AYB4IbqM~noxM^)FXl?D
zc;eY7&+pk2GfA1dM~LBKA)1Z$SthTGs1Ky~raz1C^O(PL<Fz7RQI#-jqs%a3u<pdV
zam`PWyA^N>^F07+0q((doTRBNIU+*_A|qT7EOVZ?i0%c<zNY<3n*yg@p@hN1K+_?R
zHVA*C^!Nwcz5vgVN7yv7o64X1+lHneKXA{BEA>wEKc4vVn|AF-w3a^T|K<x5$M3w*
zVRmaf#ya14;Qnvfw8!uI(vMt$cb<6k-^u*kpM7%!j&;5B_ym``1V<iscoh-8m0K_h
zGnQjKwDcmO-qDb~cVINEl~3v=jE#C#z46T6dq?S#y*H0yF^ETK2o2n<g+GLU_A@kt
zY3dh{0XvX8_mmRBq1ldszCepT2$gs+gc6*5Lt4YyA-$*y7|z{|jpp4Al(pL_yZT%l
z!EWAYx0^TO@bYshuj}2L)muhXb)<4s{l*9p+<&gF9K`|ee3~N;dslB+-T#4#j`p#v
z_Xfr=9W`MsxF7J9vq6RBdfo#Jivb>9+hA`lPC~KsdIV0CL0j-KOJypSD~BStv$e$S
zk;(@mv{ME(y^Qgd#yJ_a;~C5(ZjT&ae2(HR_!dR^>=8ovizBqs&Rq$XC{0axu5q@N
zIa)w17F1@zY@d0)SP7|s6+@(5av5Cfwioad9G})0VAQ&pYDg7|xYl~?t=91yf?ipd
zc?mod%>e&SS7Ab?v{aa|FOX`^fiqOXtkV<E7FEAI<}Ob4MlEXV=XAU(1O%tA3=cPV
zHo+sdyQ#Bzc=*24qWA66gX`OU__VJN>bvTrGhuvKjREBkqun?xY1N{YllfMCR@7?6
zbt^CPt@^yF)ruFT#4EDY1(Cx~u)yWAwqWrbEdKO?$Bam&Obw$s7l&pr8)BU}?CIH2
zVo07xF+kI2FqG#dr%^8vj3va|P42rX>Fx7*KY44ho|}NZVB)Jh!lv?{yzX|tz1RO$
zCSs<sGH5Ie8#CUGsZcjtJqfJlxz#4<;Jck*BTfh82}l-v<^WH{p^XC)1~ztM1Nmq#
zq-3Pfia@P3E6b#mAqSkSGzE9k=lrRc`WhRwi-1k26ML60V)}ZMdXwgMc*kE|Wxg=r
zhqG6Hqg-ZDLAiMA`>wd?A|14GN<q>RcTS0=24&Cg!L@hYwROt|t~V-Q25s;eIp?F^
zRh=a7KM+RzTs<@at>Jnz^J8-sOcD90GG4DA?UO%vvj=sTTRl2=@905UC|53&%W%(*
z-JPs1g>Zulx3U^e2c-RLAke$5ms=((D%b5r^$~{!S8oyX&6V=97(^2=-dXM7^UV}&
zn8WBSDi>f;2bJe}1!@ni9jjc0A1zV3Y3<d8e8tsOx2=5f9x(JGLyOBI>wqn*5_IIQ
z$2Bl>6vuPxceLa15!(&@lCWsULFDK^kRQktb`I1pP7&E<`EkXv#Ha?z92+}elfuXM
zAtiRBVcB+2<<*={Qk7ZSyK`r(B`~yQYo`0K`IgHbY<JNeJ?X7mh5{|Iojdl?$PZlY
zLA&eK0rR%t@P^g)V6bQJpjUl37__Y(+SnHM4(_F059~VRY1p-W`)tRG-c5f(cjA~~
zyQlKPrrs4Dv)i}tYVaJ|h1L4C9(z-}$9#5+dBATEuG=)|4>n$~x7|NDvMy+ETCr~o
z24~jVpjrmDSbUpL`-L>jM`17$GpAwdTdjrytp31MqpTKrBwkWfW4wZfP6hE$Y1~xC
z1%O%PXm0$@<2r3PM+Zm+Ov^%V&h~A5PKUIu;toVVF1IT-B;*UO26ll}6&IvP37YNZ
zrQuX{@!GPe|4NN!<?gBu17uT`+J&U610^h9FPmBBy~PqMii|n<ui{-1WYjKZi<lZp
zA_)k66;P0+2gswmBx60X9FS-_)KqMdf%%`@bXe3$k<=rkd?2HaQA7?fP<iUual~hO
zV!qAO0ZA_$64WAPKITCRAcs|;zFNO0*r>vT9TRdl>cmyy8QP+K3ZALqGIxgM*1=qm
zG`J+{TL1;siK$6Z!h)%$&Yn-zd62YH2`fDASLTmD*FLZ9#<|4oU5s~dV<1{jjy3E?
z8J?hn&9&AGEj690t-6V~tK9T4^Xa*)W9~-Uee@$O=0WKfOk9ICoq6v~>|EfcWXng6
z((W7Q9N9VZ>7PgkWa3IU%8H*}r)}?ECu^G%MqHL>f%}4eGu~}{_c|UyvEvNj@oM1R
zYg(V4Uu0N<*VVxC-5~kyHG8*uaG6p!!s7(Xa4bP>1!ZD%)dsqqy&J9d<t{eGsht#^
zO`hTrPE(!_n=gdlEDhqUJ{oH7Y#1yte1tNN9;;nTmbyoAWu1^()b(qdT3VW}tz|Hf
zd=KrezI8#(5oTO}$-@?E>Mv?fScDW46T!rUC?PqSXoTJBY3^Z7XW;-E6#XPdd6cWy
zO-m{ZWv)A4z~XdODPtit%(rL(Uxh=baJ8t0`w$3N1X=KTd(e@|)g>;8^`A~0`oS6-
zevM0Nrn~hM5Nc`Xpf1r@y};Z>-{pJevskG5Bl-1>;^2<aP+((yes2w$&*Ie+x|N~%
zR>XW3V)Y67E~-YrvA4Cq()O_bQC_EDKk)FD&(88gd=`j2PlRo3;MfELP6+IT4}MY)
z?Ra4OmM!M9SNziz`}WbN2hEu+TX2hZ_Vt}JHDJEN6J6?$n6ESsZ(IX!>*_t?x%uqA
zeRzfLA2j3JAK0;F3++U59B2IsI};Hvv^4tGY#M4-mTVANUvr_g_AeyCdbyXG9y7su
zlL=5R>?FDac77PxG^k<dXw=%E<QeJKFnF=!S5m6B5Kf0L<Oz4}QI$PiVNb~Ca9U;%
zpT+@AV7}<;qU!WCw^fd`HG7;;<AEXTI=D61yEJp4YpgfRWh*!HVavjSQ<uToA+hZd
zuZygp2jI0V<idC>){S&fpbINUJ?ITk9n)PK*7B-zL!QQC`h=QeExC6@(}8_J*N$Um
zm=<{UE0O4Oy@*i+lRGHCRsGm8p{Xn}l4(FP_Ls^D(ES#Si=Y=-u3SeuTiHBW<!Kx*
z<e#PN#~3c95?{|+zOIv^W7>rBm)fn0cJp}_18orE@ON6fF=ZSW)%uSy#i7lW@!A$&
zdVKKp&qlbd$QwKdfRU49ZgA!Ajz?a92-H)ZBbEh!Xi=>7!sT>NeE>PzwWF!RejbDi
zQ6IH}()_f=DGeSN#&w_xrVw134qY!g8!8_iJPdSKBOR5vgLE*qar8#zOALrJV8DHU
z78qcv3@`u?&VjQ*;zDdMz+MFGDL6EEHhBCTdlRb3&g_GZvzE{Of0lB;FB`lp`~eyT
zAF2N+TCley%;jKTi)Ll+=^6$p@C#%F{L;9i8R|>RI?x?2q86UvBjhAI<k{6y$Jh;f
z8I~mLMlXR{$j_WAn*T}RqMSp?0qTp#Bd-Co$`}?DkPSMs%!I^MGTn=KRbN)`A1SM*
z3(r#GQFM*$AoSp}<?7YP=tUvl)U*>?K!)kE?PTO#zLkxl`<5+iTxT%W!Sau_uZBIH
zrL4OpcUrzMi#4IVQ>Qex(a<o{h5(M0?e3P5Zk$2PG6*eBMz}A9WiXqLXsOCa$JHB$
z&11!1ZG(A+dU<6lcNTXk2reY8Hn4b8gqj{a{Y{V0RAgVGW5=bXhN9NB^{mibzIDrX
zn8f|3wMVr5XpaLc&%6$yjD}hUv=G*AMj-~+X^3|Nv_1dK!ZUAo-#hip)@RHaY$+Zx
zi*M7#RQ^SVKAJK0`v%qPG7}k?r#|p_W%_a*ox`vyi@81TpMXsO_9<ar42$l~?6uu;
zGRE2wwg(uM6Jtyc5I__GY<YX>Fl#yv9b(7NhYzzK=tGBCKT_wExyHJlisErKseDa1
z@_6MeE+DFVD46$#;2nv*E+58Trkrw%#Mm=VXT(A~<Tjq+iw>@(Sd92$=C9RAZfxVk
zJey@JHu$K9|9|eIFIi$U`GPQ;2_aNs+>y+sglJgjc>U%3^-=M3xqA>66)f3u24Q(E
zf>X&5Sc<|Ac+pPWF~?CF{>$(F!>CFEUv*Vy%x$1<lm)H{eRy%{nUEZDWKtO`OF9x1
zsc5~2YVms%ARt?j(U)}VJ+&mCHK=Nd-%!hZrX0TqeaIbJgAztUd(epqo%Fvmij5zG
zaL9!nAkc{Ly?*rz<_}#Otyq2d>h7NRkB{2TH&^VvVxy~n?`54d*w}yQ10(*+u2XxT
z?pPT=CpdWU+Vet}4##^|w(WS|o?g$Yr#Bo}yYi}Q&WD_!0MBtL(*k5^_O_?A2;5~=
z)h?<%m%27ASB|j~jP+nD)|@|GAE`ZsTCN<QdFA4{?LWc#&<dfucfu(lntA4F-_PUZ
zZs+2u-XF)Q-nW{+9mA>KcY3=u6-RVC7mx406UTRtk^fd4-~DkfMBXKPDl|~EbG`J4
zw2U4GKK5Z*UL|4xVzd>yFl@_V4V`_{F5ZpC>y>!GIx@Df)RcO44WoLwW<s^ABQiYL
z&K4ZzH=xC{lzUa%N75nI2-!s(=>{#r0!G}0>TFC!MjhvM;2&1O?|~K%|5CjmH7^!k
zX*a9C!ODw*8Ow6+-DuSt%R6veb;I)B*#I-f4>`|beUh}_S<+Vt)?pn6%K9r22cxL!
zvzC2cVwxmDndPOR5U|g@P33CZQ{oNz9*0f+Y)`n#pgi#IlE$_az-aau2XyYk@WxCG
zD;1+4B=n*asNS}bcUtwTQh`ZSu3Xd<3^r}U!6@VpuL=7JuAjH@EnNa#4}yc9IPz$F
zbGWrN+}!bK;RFx{W@(~az$*-rQWeN$;2JPuQWXlb#A+@;NGKOU>K4tFv@r0Ucjlep
zd%1T&+F<+7zMr~eX_U(HH$Uj=YBqn*dVsgOM!N7nG)6QwRO7r>EwOG%vEJifsRl4F
z>m1zyR2~}|8oOJ%^byx89Ls>C2kZNMk}+F63C_@h$txdTDrT~S*k^1hdc|n=r!j9=
z$a9M)>>;P$bFN>j_2^jnb<7j$^xop(hv$0O4o~)%ET@0@a>Pk??BHM*3^GQU=<197
z`V5Nn&dhj2UAQ)_^Y~@Bu4wZk)^nNG?^)M`RCo_{K&N5td>nnko{>-sR;b}0two#B
z@=n4yf2KLLs<dUc@4KqH3F4}K+cRyFE>|ARt7`tBxQ5jOWBb2)=$^+^^|5;nKe>PG
z0R1i0pnZLt*W9h;WSWBqbFv<&Oc~YjL%Lq8D5p~x&YZ>SGiD69LO848P2*y`)A?FC
zxd$%79??9%b9cHw)3EB;A_J0w8B!qK8#u%L5$f@JpYjm(E%KPU{@`8Pwf&C$h^;|v
zTjoOxvBR19A7*q6m$o__xc@$;nfrp_;9{V@FJ@~Wi8P`Q*f%HRTdUAs*OY9r+SXvp
zrp*IT_Yc4jQ~2U^wmCxRsSxa(F><h9_gayPI(m9G_Vo0rG~x<48t7{Ks;=JmcVWrZ
z6|lRtgU(gG!@KKi_Pef4UHCWeceFO_Y;dbKH?^%g$3|Pv^Si|jyT^R1eZWbu)Tx6N
zxNj}2G_m~@0D_LhQCF*;U=!sTKISKyg7t;(!R>G`C}2?*@}~z}y}Lf=P>&;oaT=>f
z0Kwv>thyq;-qE9m)Rt8(xb}2N_bqiLJ=Na<P8xtRTj}V>y22`U+p?;z+*Z~#*S2Vp
z0)z$HMvw=30OnDEI?E;X6_8~HxtSN>!1=NB&(DX)0%PwP@IW6}vjMcS$10n=%t-0V
zv9Uz3b^W%rJ)Lbk_CXgf>a+lTUl?I8&p8Q)pP)=hoCLnUlXa!txXEiOs<Kd{z*IZg
zk_}_1Jp2{fgc^ltWehVEInW{zvbSt+i=e;L*V}thF3$n6_N`$~0XrHYDRe`1V~5re
zdy}zaxpzb@vN>J33}p$yROXZ-qgZI?5ARZC#SzZr!H4Arnb1_qs$MP&d_vJ&KFcYv
zqQLkhR&O>!{t%;<gD|1Nx$PK^coBW`JsbN6CW;dS{gqcWg&uxbti;j7{rUMzE}74p
zSUyLy&>FsQzOr2kbWUiE{8FiGhFNCe**}}%v65wR{D-_M-)TgBFr-!g;A2^;qS<VM
zwFWrryR4Rr{NXc{aEHfXU3(8gZ26<u8D_ATXvE?uCLz4l2$YK%mCD?91dJASAmzfH
zt9#Az>Qz_lZf?7G=8C_(c~515rmrsCp+c*thTnVF@QShi=ElAYg6BUOoB4}SV9nIs
zA2Q}uU3=(eZl)@Dc?tO9;u#@hFt2}wfVFb;!f2-!Zu`7?vaEjow!(|*k*lvhqKYe}
z*S&n#T`#Zu8@i`@3lFS3Ul#NzxH6=1^gA%$IpBca2)pRe{hLRjhu92d5^utmqXyW~
z$zAQqWc#ksW1|NS;8GXM)mSyCNA>;X{rU&TKNt++QX4V9SYs%`Jy;#9oS4VM(2+zp
zLfCM^@PjnQk8-J^m!QzOVemNg(&lf@$F6{KC9E#!=D)~WzolP}k8@tgu!tW%`GrS$
zS#d1BLoMgT`Z~)@EF)>5Zq*X}U*z6?Jj~{#e3>lrL`Il~Qb`S$O2S~2FKSl_*+Y)R
z*ve%{zk|%ps%wqt5T0}bDXIYt%21fSstYT+x;Aumy<PY7RakY8)9u7hHh_zt_B&UB
znp7>KOhTDul&Pak1;Zt*5MeAsVRgJzs+7jZ`B(+$Jds4e(}IB}R}>hWuz5ho0-!AR
z^Js_qn`??sZ1@De>Wb=a7$%sDr~YBxXYi$h+@^x!7iH(LB56lhC1L*!%<Js<_VofG
zftPH*0K)^ds8^ISq<$bcbk`CqcmWm5m|-Bl4w5QK91v2dgy}D}hqNw5THAJ^m7LiK
zGDQ!vklW3yqYd{E<_U}T&}*LkJ0E<>?|<pRo%=nNhwGwL!oAo0FTIKc{GdL@G-bia
z$&`$RYBo9Q9t|RAn9Q7Zfl{PfEwlQTihr;1#0TEx>n_?(wFq{hzwGg0TC=wH>+{eT
z1PzzLJ6H~CL3y$m;ex*#ZQ(U^bzI~*4u}Zx9c-vrE^$sJ$pg!*&_uPD>h5B{=@j4v
z9b~{8H&lImya>j{dmYS$@o@<23UfjJ$oIg~Ag5#0>0=`&9&<on8}XHd0yy*r5a+cu
zIYUs<kBSV6b%?T6n^I{pL<;kIG|rMaJr=i@DCYzV8O3HAUKZ%?V`_$3*6r1Qk44#K
ztaFE}H}O<GN02w#gQ_dQf2+TM{|#c`Vvs4IR6HY;4Yh;LLlKoZDcUONg|%vUqlL6S
z99k9#=~!tuFCeq><;%*f=&0x?=q~diGfL~z{2sUA6zGi5PjclPgA|O@*@FY{qI-k~
zEx|0UIAn;L=e;BJG0)JhArI{_pArG{5A>V}d`vztw_6FGGq=n8#nrvNt95Z99Ko?%
zc#-~U$1c`@SO4H}sL0(6fS?JyptCe|EtHDYzDF9+7xppq2@P!b2>dt%Ft(Mk+ygB+
ze~OD^rPY^JE4?MI3~7NkY(mqz#Fxi;nC0*IB4Y-!Ko6iQq61OzBE7NIA8d<&bwD;$
zpj)iGg0#vKSGhl71dk?^%gobMy(oBY*^(ba7=6!_<QAtkI3EEz100~h-YPMBhpMSw
zl3R4HXZz>e_rB+Or>*TBZwGtS>!=gtPCi$orTn+sZ@=i6f7pM_MZRDoUD_D*36yjQ
zJS&4QSMA-Uc2%-1!K{EGi*>6zy^$T$K&v2+ay)Ald&BsY`EvH!sjI@+oEav3tzQ9d
z)a@7AXHhqnxJMY~s0uStUI(q&{<R$)fU4q1Pl7C{)hk&?pbHeHDYyCp>Q?x}s5o#i
z%vLqvdlntVES>bJjp~;+PC*lSO2I`P4<5*>YW6^|$%loIwl)Z<zNX;z%ZV=Eet@Mb
z2&WKK#T6Bmw9R~pxzfnS0qn1Uk&NW@vc?je18r&;17fk%G<BP%&Rx@d^y;gRHeWNR
zYPU@_g&AE`4qkVw_u%!adi_D~t=Dlv=6fjP)LUv1uweND{vcq@3qcBMFmZ^ws-IvB
zYFg`TY4)HATDY?9gfPSYsnh22yR{b7m|@EKl+}OLT4B3_e8gGz0qX#EcHAp;!YA13
z#QZ7erJgwXYi_Li-0$42?QRr+?2r%e2J~=DbAc8HFgF}iE{CRD0WT4HCd>hZ-l?yf
zSEE}yRcNGetTk4sRi|{6Dvy81Obm}PnsYNSP_mC8p2%+MN3x}e7U}5eZLb__@9pVO
z4^@t>IIpLrrRTgA>LDw1k1r7D2n5VKyv_Xk1rcrb${+O~dpgX{_TJuhdaVOal$!CT
z*@-usIYh5<=!s^p`4W=*DB=q&g}u!?DB$b#1_ItrU*J;+a+*%BfF<+j-)lE%?9Y8C
z+P>L_BWhqXXJ;=Y>_v^8s|^gYBQ2;aU_=M~3@qxL!c536qind&iAU+YJE;F~vu1ag
zC0CQZ+1t?aOP6LRZO^0ou8Zu@e-Le5=WDiU4qww9<|~J<I$!;BBnvgVgKL~kjzOqh
z)n~8Tbt~L<`t5JnHRmcM|D?UirKzLt4P!ryUK`orb+>f5{C01{Rl7cl!hQnvZEt9(
zDTH?~MqU_cc+4qlW5Q~?7am)<Rak@&=5Vzx!Z&t9X41Hig++It>_5u`Zf%6drD9O3
z3gt2b&&C!o1%x)yWD>$M09>W<4>mF~u30Ms`bnfvOFUgffOLMurlPfB3>LD^I`D7@
ze8<7o9JIzJVU~#)e13;;$US?FU>{{m292<!fF~g7;|!ML10w_dYpOoas><%i)sobv
zSH{Kf^ZC*D;N;`EMvp_a`8|Ds{ZDQg8MyL-PwiC6ZgV^rfTgg#(Mg-UcK>@#llJd&
zYW8#NjgGF?24AZkmI;u`t-LmH-hX|CY2J_<-Rp8AQ7GW@Y#O+7|C8I+x`s$|G}@1%
zh!sxF_8oU4t;~+XQ5TYNo~^!fTf3m=#-~~Nx#U0yx8W<e5w1?eWLQObl@(^`x7P~W
z5MF16?co1IR@j02JFKu9mVg&pVU~(gfH>J2P|iB#s1-)%Dvw%W72&U0VGZHut*{N@
zA6sENY(HMN!VcVj#|pcZ1LU;A9;KhIbzhh@qIqL7JTV`R$I_{(bSm#Q5=PQU<*ziR
z3yEmfidjDgjBGBRPKAdCH&}NrHd02mnn&))^l&~u6`snbli`bynUP4O!<lUQdLxz}
zoXO`i+t#n=Liqk*ES<z6=7maDF_fs1$8}N(D-&=_7*^s+3`gyz6h3e_g=cO&P2iiv
zm*ZFBcUmdnUR1na`>a0Y0i?`gfiNy|3ZoMSL3s6PE*80Ry3^~CQ;wjVVU(ChUBdWg
zkxvq#i>#V)P6>p=aHz=7u18)mq#lG(KEIWLi?{XotG1+?eo(Y9iH>GjRD9gl?`c2O
zPAX|+!|JXB8Uq(KkefWv9{E6Berm$1TQknoYo#^{V$fQF^c}R4R)N{G+q*DKV0x&R
z)=(eyW4e3}t)+F?m2n<LK(Fg*1H5t%V>NsejnHNoXKkUav<*8_cff*-U3cj;Mi<b9
zw43(OMOZ|>1RELl(muMBE~Cro3VJWSkFKQm(^a$|Ho^z#YWe^jqQi6zT}#)|2Qht(
z(gek5k_?)nX_}!pT~D)=pd_UzO*mhRvXrAd73c`f(G4_DAEuAcjdT;;Ot)a7eU$!$
zZl#aX$LLS#HcW=^ppVm?^a;9)?xsJZd+1*JbNVFRM}I+|qEFNP^Z@-O{TKQSJxHIW
zhp0rKqtDY{(Zlp#=@|Vt`T{*d|D7JCFVbJrWAr!lI8@VLqA$}Ebez6IPtsTEYxH;Y
z6g^FUPhY2RU=r|6dWN2*Z_#t~ZF-)*L*J!;pns%)qVLi7=>>X`en3B@m*_|IWBLjG
zlwPKPrdQ}^^grlT`Z@iA7U-AQk^efqLI0C}MgKx?(*L4=rT<ND(XZ+M(Ep|1&~NE?
zbb|hk{+)hL|3PolJM^FQ2Rca=GU=46s01fFIEP5Js}2?05!D9Ot$I|i>QfuBk*i4!
zsLg7N+N!pxL1;f$sO@Tp;*ZCSNDSnijAYY=)MV3AJoljj=-QP?Clc{YI^)Zv^U-`d
zlFMh~nVc&bPs~R0Mzn!nn9RmgvrZ1-jw6<ynlcPWIvY(*8@7xQ&Dx`xOu}%)W{hlh
z-aQ9CkWCtibjm)Rjb;pQ)|kr1r<p&bv(3}#iG-1l$kHQIg+#(vcgthN_=!V?-LZ6H
zQdR;%E<6$|WEw;{A~ciEM?_52Ry5Z+Y2<Q7HgC@rrlZ-0d=#b5p}mfYLKanV&!*y2
zMr10ToptjilFOviDQ9dZ9-B2%wyAV_(lHs&%|wQ5{3DuRAeu0y@;+`4N6@I5ydSxy
z;;HFKx{y!AQ-(8V<nxI8GBfFvQN8O;rn8F?*Hk=(OpQs~k+?DEOr)cec)@99YXe+o
zZMk$dkC!Im2_qtMLcZA}=mJ{~T+@|fh@eErBp7hsuqWfGLe7>+6moX-LTuI^OQds#
zZ!!|iX47-Brrv6d8{w7@YJ!vLxs<0Sxl4u%8Ew{>w-r#lqmW9ZW3x8CX!&%e0biN5
zi$k23UBJXP5sl3{CerzQI_Z@^GM&eYh+>m~cqGLzv~jQrRW3}<N2008NUo4d&HFOZ
zY(6m`k@p?)2mOgh4qO4}2K;2>IU_RUv_kF~BYI>$l8wew4GGSgyT&a-;--5pj_fn(
zIU{Qa#>8fv;u;yU=i^Bu>xp8lOLB>^S#L6mHl<_HJcc^Ig!5wD8=ah7jI>r$MdBDc
zvq|wr)8b7FG3=Q{G-kMfPO<b6BkP-rXLI>TG(H)bjptqYY&tVD9~pAUfIVqUMiWkP
zjSMy90AEp2Eg6rwCZaiGA`0@zrDHK8>jJjsIqt@TSwjR}xqLJm0j>CA>CAj&G94@M
zXzpREisV2cM#`6)ivs|Wi2}MQrA6aOTMU(Or6<!#pj6hz5b?(1*%(kzAXQ`5NTTXh
zoNlq6G1gV@IVZprK0H&fO#n-q0Y`LBDxHr{#U&iws6V$9q|uTuWK&$l$kIJmCY_5j
zyg87MMYEFtK`s}aHkx8H(R_pn%wipWabpscFIh?BVq7pfkBG`gGO2i1Jada@BIp1X
zMG6^LwJx@F#z=w5q*4%7P!Aj4n$=>Nf;~S|NKQDWjAS%n*!W_fF2pAd+l-OO*iyz^
z&X${rXYBcGAvWurh{xsuwuVerFwN++?Dj$`@0deJ8F^bQ9Z%Vl0K<F(KsFoUwrUgU
z8=M)i7W7nOZYG_{FkhL7PDh68qeK41r+6<P89psO)Lj3Zd)=8!re~QLQsD72XrD=g
ztlfe)MiS9v#*vH7q|*sMI)&>!QHUp)GkNoAu%Ub;17@DZ`*~v`ot}*hF9nC}Q%I3x
zMxBs=YseZgBYp&L`!n$vz`KYxZKuWMz~s&qfQ>-x$dIcVv?0@^*O-jwtLW9ZR6ryj
zo%T&e^HDI2XktDW&oyM?jHm#JTil;WGyY)g<2HDb(dl>$qZ=4Js+rE>CdMgr0YK0o
z(ldhCC$fdyj1ydB5}4samqYYTL|wU9JO`GX^BzH;r_ozr7Ey=f&(3H*pN+=yPIPA+
zL!9fw>2#87>=c1`3P={s#%3JyEpI+LYeeUw^AYauLWbG@u#34bhZ+QsV;GWw32}(X
zS#OniBj7nmCn>q98jz%EO!}5?fa3j&G0C`=;*y4)3<o2hYn;MBVq_OHv7+)NpB0m@
zV4M_5QH$yWCKhATAs^VdqZauEQ@#ygH5!1Sz#T9!uMEi{z#}7KxNys`7(a68KhPIO
zOS|9_&9wlJVAzXKIwfyQ*}%~XK8rtCk0zOr3{*pOP&Iu4Y%-U#Lpnk4RQa;g!b49D
z8?!M3cUT~@26oqn3I@<7opSRSD6@3rBGXx}i3A*?j6wKeQ24+NGZ~EE1)#&c3uy}A
z-$^9SgSc}1QUctz0jYt5shEKfCPB=fIfq1a+LMbUqgnJ15Esd(A&unlh*=GOn6~l5
z!1r>(6xbubjFM!pB0rw6ftdIuaC78`*;f*LIGsWmZ$cJhE}M%K(BQ<R8wnE8JP%$d
z55$4-i7U03(k;6IiRL|4FbZ(kWz{4SH?Yitp=K`S6rn<<1q2rZt3<<*X?z;ZP1wbZ
T(QA#TqC*?2Wk<v_%zOSHdm+<b

diff --git a/test/mfa/backup_codes_test.exs b/test/mfa/backup_codes_test.exs
new file mode 100644
index 000000000..7bc01b36b
--- /dev/null
+++ b/test/mfa/backup_codes_test.exs
@@ -0,0 +1,11 @@
+defmodule Pleroma.MFA.BackupCodesTest do
+  use Pleroma.DataCase
+
+  alias Pleroma.MFA.BackupCodes
+
+  test "generate backup codes" do
+    codes = BackupCodes.generate(number_of_codes: 2, length: 4)
+
+    assert [<<_::bytes-size(4)>>, <<_::bytes-size(4)>>] = codes
+  end
+end
diff --git a/test/mfa/totp_test.exs b/test/mfa/totp_test.exs
new file mode 100644
index 000000000..50153d208
--- /dev/null
+++ b/test/mfa/totp_test.exs
@@ -0,0 +1,17 @@
+defmodule Pleroma.MFA.TOTPTest do
+  use Pleroma.DataCase
+
+  alias Pleroma.MFA.TOTP
+
+  test "create provisioning_uri to generate qrcode" do
+    uri =
+      TOTP.provisioning_uri("test-secrcet", "test@example.com",
+        issuer: "Plerome-42",
+        digits: 8,
+        period: 60
+      )
+
+    assert uri ==
+             "otpauth://totp/test@example.com?digits=8&issuer=Plerome-42&period=60&secret=test-secrcet"
+  end
+end
diff --git a/test/mfa_test.exs b/test/mfa_test.exs
new file mode 100644
index 000000000..94bc48c26
--- /dev/null
+++ b/test/mfa_test.exs
@@ -0,0 +1,53 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.MFATest do
+  use Pleroma.DataCase
+
+  import Pleroma.Factory
+  alias Comeonin.Pbkdf2
+  alias Pleroma.MFA
+
+  describe "mfa_settings" do
+    test "returns settings user's" do
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %MFA.Settings{
+            enabled: true,
+            totp: %MFA.Settings.TOTP{secret: "xx", confirmed: true}
+          }
+        )
+
+      settings = MFA.mfa_settings(user)
+      assert match?(^settings, %{enabled: true, totp: true})
+    end
+  end
+
+  describe "generate backup codes" do
+    test "returns backup codes" do
+      user = insert(:user)
+
+      {:ok, [code1, code2]} = MFA.generate_backup_codes(user)
+      updated_user = refresh_record(user)
+      [hash1, hash2] = updated_user.multi_factor_authentication_settings.backup_codes
+      assert Pbkdf2.checkpw(code1, hash1)
+      assert Pbkdf2.checkpw(code2, hash2)
+    end
+  end
+
+  describe "invalidate_backup_code" do
+    test "invalid used code" do
+      user = insert(:user)
+
+      {:ok, _} = MFA.generate_backup_codes(user)
+      user = refresh_record(user)
+      assert length(user.multi_factor_authentication_settings.backup_codes) == 2
+      [hash_code | _] = user.multi_factor_authentication_settings.backup_codes
+
+      {:ok, user} = MFA.invalidate_backup_code(user, hash_code)
+
+      assert length(user.multi_factor_authentication_settings.backup_codes) == 1
+    end
+  end
+end
diff --git a/test/plugs/ensure_authenticated_plug_test.exs b/test/plugs/ensure_authenticated_plug_test.exs
index 4e6142aab..a0667c5e0 100644
--- a/test/plugs/ensure_authenticated_plug_test.exs
+++ b/test/plugs/ensure_authenticated_plug_test.exs
@@ -24,6 +24,31 @@ defmodule Pleroma.Plugs.EnsureAuthenticatedPlugTest do
     end
   end
 
+  test "it halts if user is assigned and MFA enabled", %{conn: conn} do
+    conn =
+      conn
+      |> assign(:user, %User{multi_factor_authentication_settings: %{enabled: true}})
+      |> assign(:auth_credentials, %{password: "xd-42"})
+      |> EnsureAuthenticatedPlug.call(%{})
+
+    assert conn.status == 403
+    assert conn.halted == true
+
+    assert conn.resp_body ==
+             "{\"error\":\"Two-factor authentication enabled, you must use a access token.\"}"
+  end
+
+  test "it continues if user is assigned and MFA disabled", %{conn: conn} do
+    conn =
+      conn
+      |> assign(:user, %User{multi_factor_authentication_settings: %{enabled: false}})
+      |> assign(:auth_credentials, %{password: "xd-42"})
+      |> EnsureAuthenticatedPlug.call(%{})
+
+    refute conn.status == 403
+    refute conn.halted
+  end
+
   describe "with :if_func / :unless_func options" do
     setup do
       %{
diff --git a/test/support/builders/user_builder.ex b/test/support/builders/user_builder.ex
index fcfea666f..0d0490714 100644
--- a/test/support/builders/user_builder.ex
+++ b/test/support/builders/user_builder.ex
@@ -11,6 +11,7 @@ defmodule Pleroma.Builders.UserBuilder do
       bio: "A tester.",
       ap_id: "some id",
       last_digest_emailed_at: NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second),
+      multi_factor_authentication_settings: %Pleroma.MFA.Settings{},
       notification_settings: %Pleroma.User.NotificationSetting{}
     }
 
diff --git a/test/support/factory.ex b/test/support/factory.ex
index 495764782..c8c45e2a7 100644
--- a/test/support/factory.ex
+++ b/test/support/factory.ex
@@ -33,7 +33,8 @@ defmodule Pleroma.Factory do
       bio: sequence(:bio, &"Tester Number #{&1}"),
       last_digest_emailed_at: NaiveDateTime.utc_now(),
       last_refreshed_at: NaiveDateTime.utc_now(),
-      notification_settings: %Pleroma.User.NotificationSetting{}
+      notification_settings: %Pleroma.User.NotificationSetting{},
+      multi_factor_authentication_settings: %Pleroma.MFA.Settings{}
     }
 
     %{
@@ -422,4 +423,13 @@ defmodule Pleroma.Factory do
       last_read_id: "1"
     }
   end
+
+  def mfa_token_factory do
+    %Pleroma.MFA.Token{
+      token: :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false),
+      authorization: build(:oauth_authorization),
+      valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10),
+      user: build(:user)
+    }
+  end
 end
diff --git a/test/user_search_test.exs b/test/user_search_test.exs
index cb847b516..17c63322a 100644
--- a/test/user_search_test.exs
+++ b/test/user_search_test.exs
@@ -172,6 +172,7 @@ defmodule Pleroma.UserSearchTest do
         |> Map.put(:search_rank, nil)
         |> Map.put(:search_type, nil)
         |> Map.put(:last_digest_emailed_at, nil)
+        |> Map.put(:multi_factor_authentication_settings, nil)
         |> Map.put(:notification_settings, nil)
 
       assert user == expected
diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs
index 7ab7cc15c..4697af50e 100644
--- a/test/web/admin_api/admin_api_controller_test.exs
+++ b/test/web/admin_api/admin_api_controller_test.exs
@@ -14,6 +14,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
   alias Pleroma.Config
   alias Pleroma.ConfigDB
   alias Pleroma.HTML
+  alias Pleroma.MFA
   alias Pleroma.ModerationLog
   alias Pleroma.Repo
   alias Pleroma.ReportNote
@@ -1278,6 +1279,38 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
              "@#{admin.nickname} deactivated users: @#{user.nickname}"
   end
 
+  describe "PUT disable_mfa" do
+    test "returns 200 and disable 2fa", %{conn: conn} do
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %MFA.Settings{
+            enabled: true,
+            totp: %MFA.Settings.TOTP{secret: "otp_secret", confirmed: true}
+          }
+        )
+
+      response =
+        conn
+        |> put("/api/pleroma/admin/users/disable_mfa", %{nickname: user.nickname})
+        |> json_response(200)
+
+      assert response == user.nickname
+      mfa_settings = refresh_record(user).multi_factor_authentication_settings
+
+      refute mfa_settings.enabled
+      refute mfa_settings.totp.confirmed
+    end
+
+    test "returns 404 if user not found", %{conn: conn} do
+      response =
+        conn
+        |> put("/api/pleroma/admin/users/disable_mfa", %{nickname: "nickname"})
+        |> json_response(404)
+
+      assert response == "Not found"
+    end
+  end
+
   describe "POST /api/pleroma/admin/users/invite_token" do
     test "without options", %{conn: conn} do
       conn = post(conn, "/api/pleroma/admin/users/invite_token")
diff --git a/test/web/auth/pleroma_authenticator_test.exs b/test/web/auth/pleroma_authenticator_test.exs
new file mode 100644
index 000000000..7125c5081
--- /dev/null
+++ b/test/web/auth/pleroma_authenticator_test.exs
@@ -0,0 +1,43 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.Auth.PleromaAuthenticatorTest do
+  use Pleroma.Web.ConnCase
+
+  alias Pleroma.Web.Auth.PleromaAuthenticator
+  import Pleroma.Factory
+
+  setup do
+    password = "testpassword"
+    name = "AgentSmith"
+    user = insert(:user, nickname: name, password_hash: Comeonin.Pbkdf2.hashpwsalt(password))
+    {:ok, [user: user, name: name, password: password]}
+  end
+
+  test "get_user/authorization", %{user: user, name: name, password: password} do
+    params = %{"authorization" => %{"name" => name, "password" => password}}
+    res = PleromaAuthenticator.get_user(%Plug.Conn{params: params})
+
+    assert {:ok, user} == res
+  end
+
+  test "get_user/authorization with invalid password", %{name: name} do
+    params = %{"authorization" => %{"name" => name, "password" => "password"}}
+    res = PleromaAuthenticator.get_user(%Plug.Conn{params: params})
+
+    assert {:error, {:checkpw, false}} == res
+  end
+
+  test "get_user/grant_type_password", %{user: user, name: name, password: password} do
+    params = %{"grant_type" => "password", "username" => name, "password" => password}
+    res = PleromaAuthenticator.get_user(%Plug.Conn{params: params})
+
+    assert {:ok, user} == res
+  end
+
+  test "error credintails" do
+    res = PleromaAuthenticator.get_user(%Plug.Conn{params: %{}})
+    assert {:error, :invalid_credentials} == res
+  end
+end
diff --git a/test/web/auth/totp_authenticator_test.exs b/test/web/auth/totp_authenticator_test.exs
new file mode 100644
index 000000000..e08069490
--- /dev/null
+++ b/test/web/auth/totp_authenticator_test.exs
@@ -0,0 +1,51 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.Auth.TOTPAuthenticatorTest do
+  use Pleroma.Web.ConnCase
+
+  alias Pleroma.MFA
+  alias Pleroma.MFA.BackupCodes
+  alias Pleroma.MFA.TOTP
+  alias Pleroma.Web.Auth.TOTPAuthenticator
+
+  import Pleroma.Factory
+
+  test "verify token" do
+    otp_secret = TOTP.generate_secret()
+    otp_token = TOTP.generate_token(otp_secret)
+
+    user =
+      insert(:user,
+        multi_factor_authentication_settings: %MFA.Settings{
+          enabled: true,
+          totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+        }
+      )
+
+    assert TOTPAuthenticator.verify(otp_token, user) == {:ok, :pass}
+    assert TOTPAuthenticator.verify(nil, user) == {:error, :invalid_token}
+    assert TOTPAuthenticator.verify("", user) == {:error, :invalid_token}
+  end
+
+  test "checks backup codes" do
+    [code | _] = backup_codes = BackupCodes.generate()
+
+    hashed_codes =
+      backup_codes
+      |> Enum.map(&Comeonin.Pbkdf2.hashpwsalt(&1))
+
+    user =
+      insert(:user,
+        multi_factor_authentication_settings: %MFA.Settings{
+          enabled: true,
+          backup_codes: hashed_codes,
+          totp: %MFA.Settings.TOTP{secret: "otp_secret", confirmed: true}
+        }
+      )
+
+    assert TOTPAuthenticator.verify_recovery_code(user, code) == {:ok, :pass}
+    refute TOTPAuthenticator.verify_recovery_code(code, refresh_record(user)) == {:ok, :pass}
+  end
+end
diff --git a/test/web/oauth/mfa_controller_test.exs b/test/web/oauth/mfa_controller_test.exs
new file mode 100644
index 000000000..ce4a07320
--- /dev/null
+++ b/test/web/oauth/mfa_controller_test.exs
@@ -0,0 +1,306 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.OAuth.MFAControllerTest do
+  use Pleroma.Web.ConnCase
+  import Pleroma.Factory
+
+  alias Pleroma.MFA
+  alias Pleroma.MFA.BackupCodes
+  alias Pleroma.MFA.TOTP
+  alias Pleroma.Repo
+  alias Pleroma.Web.OAuth.Authorization
+  alias Pleroma.Web.OAuth.OAuthController
+
+  setup %{conn: conn} do
+    otp_secret = TOTP.generate_secret()
+
+    user =
+      insert(:user,
+        multi_factor_authentication_settings: %MFA.Settings{
+          enabled: true,
+          backup_codes: [Comeonin.Pbkdf2.hashpwsalt("test-code")],
+          totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+        }
+      )
+
+    app = insert(:oauth_app)
+    {:ok, conn: conn, user: user, app: app}
+  end
+
+  describe "show" do
+    setup %{conn: conn, user: user, app: app} do
+      mfa_token =
+        insert(:mfa_token,
+          user: user,
+          authorization: build(:oauth_authorization, app: app, scopes: ["write"])
+        )
+
+      {:ok, conn: conn, mfa_token: mfa_token}
+    end
+
+    test "GET /oauth/mfa renders mfa forms", %{conn: conn, mfa_token: mfa_token} do
+      conn =
+        get(
+          conn,
+          "/oauth/mfa",
+          %{
+            "mfa_token" => mfa_token.token,
+            "state" => "a_state",
+            "redirect_uri" => "http://localhost:8080/callback"
+          }
+        )
+
+      assert response = html_response(conn, 200)
+      assert response =~ "Two-factor authentication"
+      assert response =~ mfa_token.token
+      assert response =~ "http://localhost:8080/callback"
+    end
+
+    test "GET /oauth/mfa renders mfa recovery forms", %{conn: conn, mfa_token: mfa_token} do
+      conn =
+        get(
+          conn,
+          "/oauth/mfa",
+          %{
+            "mfa_token" => mfa_token.token,
+            "state" => "a_state",
+            "redirect_uri" => "http://localhost:8080/callback",
+            "challenge_type" => "recovery"
+          }
+        )
+
+      assert response = html_response(conn, 200)
+      assert response =~ "Two-factor recovery"
+      assert response =~ mfa_token.token
+      assert response =~ "http://localhost:8080/callback"
+    end
+  end
+
+  describe "verify" do
+    setup %{conn: conn, user: user, app: app} do
+      mfa_token =
+        insert(:mfa_token,
+          user: user,
+          authorization: build(:oauth_authorization, app: app, scopes: ["write"])
+        )
+
+      {:ok, conn: conn, user: user, mfa_token: mfa_token, app: app}
+    end
+
+    test "POST /oauth/mfa/verify, verify totp code", %{
+      conn: conn,
+      user: user,
+      mfa_token: mfa_token,
+      app: app
+    } do
+      otp_token = TOTP.generate_token(user.multi_factor_authentication_settings.totp.secret)
+
+      conn =
+        conn
+        |> post("/oauth/mfa/verify", %{
+          "mfa" => %{
+            "mfa_token" => mfa_token.token,
+            "challenge_type" => "totp",
+            "code" => otp_token,
+            "state" => "a_state",
+            "redirect_uri" => OAuthController.default_redirect_uri(app)
+          }
+        })
+
+      target = redirected_to(conn)
+      target_url = %URI{URI.parse(target) | query: nil} |> URI.to_string()
+      query = URI.parse(target).query |> URI.query_decoder() |> Map.new()
+      assert %{"state" => "a_state", "code" => code} = query
+      assert target_url == OAuthController.default_redirect_uri(app)
+      auth = Repo.get_by(Authorization, token: code)
+      assert auth.scopes == ["write"]
+    end
+
+    test "POST /oauth/mfa/verify, verify recovery code", %{
+      conn: conn,
+      mfa_token: mfa_token,
+      app: app
+    } do
+      conn =
+        conn
+        |> post("/oauth/mfa/verify", %{
+          "mfa" => %{
+            "mfa_token" => mfa_token.token,
+            "challenge_type" => "recovery",
+            "code" => "test-code",
+            "state" => "a_state",
+            "redirect_uri" => OAuthController.default_redirect_uri(app)
+          }
+        })
+
+      target = redirected_to(conn)
+      target_url = %URI{URI.parse(target) | query: nil} |> URI.to_string()
+      query = URI.parse(target).query |> URI.query_decoder() |> Map.new()
+      assert %{"state" => "a_state", "code" => code} = query
+      assert target_url == OAuthController.default_redirect_uri(app)
+      auth = Repo.get_by(Authorization, token: code)
+      assert auth.scopes == ["write"]
+    end
+  end
+
+  describe "challenge/totp" do
+    test "returns access token with valid code", %{conn: conn, user: user, app: app} do
+      otp_token = TOTP.generate_token(user.multi_factor_authentication_settings.totp.secret)
+
+      mfa_token =
+        insert(:mfa_token,
+          user: user,
+          authorization: build(:oauth_authorization, app: app, scopes: ["write"])
+        )
+
+      response =
+        conn
+        |> post("/oauth/mfa/challenge", %{
+          "mfa_token" => mfa_token.token,
+          "challenge_type" => "totp",
+          "code" => otp_token,
+          "client_id" => app.client_id,
+          "client_secret" => app.client_secret
+        })
+        |> json_response(:ok)
+
+      ap_id = user.ap_id
+
+      assert match?(
+               %{
+                 "access_token" => _,
+                 "expires_in" => 600,
+                 "me" => ^ap_id,
+                 "refresh_token" => _,
+                 "scope" => "write",
+                 "token_type" => "Bearer"
+               },
+               response
+             )
+    end
+
+    test "returns errors when mfa token invalid", %{conn: conn, user: user, app: app} do
+      otp_token = TOTP.generate_token(user.multi_factor_authentication_settings.totp.secret)
+
+      response =
+        conn
+        |> post("/oauth/mfa/challenge", %{
+          "mfa_token" => "XXX",
+          "challenge_type" => "totp",
+          "code" => otp_token,
+          "client_id" => app.client_id,
+          "client_secret" => app.client_secret
+        })
+        |> json_response(400)
+
+      assert response == %{"error" => "Invalid code"}
+    end
+
+    test "returns error when otp code is invalid", %{conn: conn, user: user, app: app} do
+      mfa_token = insert(:mfa_token, user: user)
+
+      response =
+        conn
+        |> post("/oauth/mfa/challenge", %{
+          "mfa_token" => mfa_token.token,
+          "challenge_type" => "totp",
+          "code" => "XXX",
+          "client_id" => app.client_id,
+          "client_secret" => app.client_secret
+        })
+        |> json_response(400)
+
+      assert response == %{"error" => "Invalid code"}
+    end
+
+    test "returns error when client credentails is wrong ", %{conn: conn, user: user} do
+      otp_token = TOTP.generate_token(user.multi_factor_authentication_settings.totp.secret)
+      mfa_token = insert(:mfa_token, user: user)
+
+      response =
+        conn
+        |> post("/oauth/mfa/challenge", %{
+          "mfa_token" => mfa_token.token,
+          "challenge_type" => "totp",
+          "code" => otp_token,
+          "client_id" => "xxx",
+          "client_secret" => "xxx"
+        })
+        |> json_response(400)
+
+      assert response == %{"error" => "Invalid code"}
+    end
+  end
+
+  describe "challenge/recovery" do
+    setup %{conn: conn} do
+      app = insert(:oauth_app)
+      {:ok, conn: conn, app: app}
+    end
+
+    test "returns access token with valid code", %{conn: conn, app: app} do
+      otp_secret = TOTP.generate_secret()
+
+      [code | _] = backup_codes = BackupCodes.generate()
+
+      hashed_codes =
+        backup_codes
+        |> Enum.map(&Comeonin.Pbkdf2.hashpwsalt(&1))
+
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %MFA.Settings{
+            enabled: true,
+            backup_codes: hashed_codes,
+            totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+          }
+        )
+
+      mfa_token =
+        insert(:mfa_token,
+          user: user,
+          authorization: build(:oauth_authorization, app: app, scopes: ["write"])
+        )
+
+      response =
+        conn
+        |> post("/oauth/mfa/challenge", %{
+          "mfa_token" => mfa_token.token,
+          "challenge_type" => "recovery",
+          "code" => code,
+          "client_id" => app.client_id,
+          "client_secret" => app.client_secret
+        })
+        |> json_response(:ok)
+
+      ap_id = user.ap_id
+
+      assert match?(
+               %{
+                 "access_token" => _,
+                 "expires_in" => 600,
+                 "me" => ^ap_id,
+                 "refresh_token" => _,
+                 "scope" => "write",
+                 "token_type" => "Bearer"
+               },
+               response
+             )
+
+      error_response =
+        conn
+        |> post("/oauth/mfa/challenge", %{
+          "mfa_token" => mfa_token.token,
+          "challenge_type" => "recovery",
+          "code" => code,
+          "client_id" => app.client_id,
+          "client_secret" => app.client_secret
+        })
+        |> json_response(400)
+
+      assert error_response == %{"error" => "Invalid code"}
+    end
+  end
+end
diff --git a/test/web/oauth/oauth_controller_test.exs b/test/web/oauth/oauth_controller_test.exs
index f2f98d768..7a107584d 100644
--- a/test/web/oauth/oauth_controller_test.exs
+++ b/test/web/oauth/oauth_controller_test.exs
@@ -6,6 +6,8 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
   use Pleroma.Web.ConnCase
   import Pleroma.Factory
 
+  alias Pleroma.MFA
+  alias Pleroma.MFA.TOTP
   alias Pleroma.Repo
   alias Pleroma.User
   alias Pleroma.Web.OAuth.Authorization
@@ -604,6 +606,41 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
       end
     end
 
+    test "redirect to on two-factor auth page" do
+      otp_secret = TOTP.generate_secret()
+
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %MFA.Settings{
+            enabled: true,
+            totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+          }
+        )
+
+      app = insert(:oauth_app, scopes: ["read", "write", "follow"])
+
+      conn =
+        build_conn()
+        |> post("/oauth/authorize", %{
+          "authorization" => %{
+            "name" => user.nickname,
+            "password" => "test",
+            "client_id" => app.client_id,
+            "redirect_uri" => app.redirect_uris,
+            "scope" => "read write",
+            "state" => "statepassed"
+          }
+        })
+
+      result = html_response(conn, 200)
+
+      mfa_token = Repo.get_by(MFA.Token, user_id: user.id)
+      assert result =~ app.redirect_uris
+      assert result =~ "statepassed"
+      assert result =~ mfa_token.token
+      assert result =~ "Two-factor authentication"
+    end
+
     test "returns 401 for wrong credentials", %{conn: conn} do
       user = insert(:user)
       app = insert(:oauth_app)
@@ -735,6 +772,46 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
       assert token.scopes == app.scopes
     end
 
+    test "issues a mfa token for `password` grant_type, when MFA enabled" do
+      password = "testpassword"
+      otp_secret = TOTP.generate_secret()
+
+      user =
+        insert(:user,
+          password_hash: Comeonin.Pbkdf2.hashpwsalt(password),
+          multi_factor_authentication_settings: %MFA.Settings{
+            enabled: true,
+            totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+          }
+        )
+
+      app = insert(:oauth_app, scopes: ["read", "write"])
+
+      response =
+        build_conn()
+        |> post("/oauth/token", %{
+          "grant_type" => "password",
+          "username" => user.nickname,
+          "password" => password,
+          "client_id" => app.client_id,
+          "client_secret" => app.client_secret
+        })
+        |> json_response(403)
+
+      assert match?(
+               %{
+                 "supported_challenge_types" => "totp",
+                 "mfa_token" => _,
+                 "error" => "mfa_required"
+               },
+               response
+             )
+
+      token = Repo.get_by(MFA.Token, token: response["mfa_token"])
+      assert token.user_id == user.id
+      assert token.authorization_id
+    end
+
     test "issues a token for request with HTTP basic auth client credentials" do
       user = insert(:user)
       app = insert(:oauth_app, scopes: ["scope1", "scope2", "scope3"])
diff --git a/test/web/pleroma_api/controllers/two_factor_authentication_controller_test.exs b/test/web/pleroma_api/controllers/two_factor_authentication_controller_test.exs
new file mode 100644
index 000000000..d23d08a00
--- /dev/null
+++ b/test/web/pleroma_api/controllers/two_factor_authentication_controller_test.exs
@@ -0,0 +1,260 @@
+defmodule Pleroma.Web.PleromaAPI.TwoFactorAuthenticationControllerTest do
+  use Pleroma.Web.ConnCase
+
+  import Pleroma.Factory
+  alias Pleroma.MFA.Settings
+  alias Pleroma.MFA.TOTP
+
+  describe "GET /api/pleroma/accounts/mfa/settings" do
+    test "returns user mfa settings for new user", %{conn: conn} do
+      token = insert(:oauth_token, scopes: ["read", "follow"])
+      token2 = insert(:oauth_token, scopes: ["write"])
+
+      assert conn
+             |> put_req_header("authorization", "Bearer #{token.token}")
+             |> get("/api/pleroma/accounts/mfa")
+             |> json_response(:ok) == %{
+               "settings" => %{"enabled" => false, "totp" => false}
+             }
+
+      assert conn
+             |> put_req_header("authorization", "Bearer #{token2.token}")
+             |> get("/api/pleroma/accounts/mfa")
+             |> json_response(403) == %{
+               "error" => "Insufficient permissions: read:security."
+             }
+    end
+
+    test "returns user mfa settings with enabled totp", %{conn: conn} do
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %Settings{
+            enabled: true,
+            totp: %Settings.TOTP{secret: "XXX", delivery_type: "app", confirmed: true}
+          }
+        )
+
+      token = insert(:oauth_token, scopes: ["read", "follow"], user: user)
+
+      assert conn
+             |> put_req_header("authorization", "Bearer #{token.token}")
+             |> get("/api/pleroma/accounts/mfa")
+             |> json_response(:ok) == %{
+               "settings" => %{"enabled" => true, "totp" => true}
+             }
+    end
+  end
+
+  describe "GET /api/pleroma/accounts/mfa/backup_codes" do
+    test "returns backup codes", %{conn: conn} do
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %Settings{
+            backup_codes: ["1", "2", "3"],
+            totp: %Settings.TOTP{secret: "secret"}
+          }
+        )
+
+      token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
+      token2 = insert(:oauth_token, scopes: ["read"])
+
+      response =
+        conn
+        |> put_req_header("authorization", "Bearer #{token.token}")
+        |> get("/api/pleroma/accounts/mfa/backup_codes")
+        |> json_response(:ok)
+
+      assert [<<_::bytes-size(6)>>, <<_::bytes-size(6)>>] = response["codes"]
+      user = refresh_record(user)
+      mfa_settings = user.multi_factor_authentication_settings
+      assert mfa_settings.totp.secret == "secret"
+      refute mfa_settings.backup_codes == ["1", "2", "3"]
+      refute mfa_settings.backup_codes == []
+
+      assert conn
+             |> put_req_header("authorization", "Bearer #{token2.token}")
+             |> get("/api/pleroma/accounts/mfa/backup_codes")
+             |> json_response(403) == %{
+               "error" => "Insufficient permissions: write:security."
+             }
+    end
+  end
+
+  describe "GET /api/pleroma/accounts/mfa/setup/totp" do
+    test "return errors when method is invalid", %{conn: conn} do
+      user = insert(:user)
+      token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
+
+      response =
+        conn
+        |> put_req_header("authorization", "Bearer #{token.token}")
+        |> get("/api/pleroma/accounts/mfa/setup/torf")
+        |> json_response(400)
+
+      assert response == %{"error" => "undefined method"}
+    end
+
+    test "returns key and provisioning_uri", %{conn: conn} do
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %Settings{backup_codes: ["1", "2", "3"]}
+        )
+
+      token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
+      token2 = insert(:oauth_token, scopes: ["read"])
+
+      response =
+        conn
+        |> put_req_header("authorization", "Bearer #{token.token}")
+        |> get("/api/pleroma/accounts/mfa/setup/totp")
+        |> json_response(:ok)
+
+      user = refresh_record(user)
+      mfa_settings = user.multi_factor_authentication_settings
+      secret = mfa_settings.totp.secret
+      refute mfa_settings.enabled
+      assert mfa_settings.backup_codes == ["1", "2", "3"]
+
+      assert response == %{
+               "key" => secret,
+               "provisioning_uri" => TOTP.provisioning_uri(secret, "#{user.email}")
+             }
+
+      assert conn
+             |> put_req_header("authorization", "Bearer #{token2.token}")
+             |> get("/api/pleroma/accounts/mfa/setup/totp")
+             |> json_response(403) == %{
+               "error" => "Insufficient permissions: write:security."
+             }
+    end
+  end
+
+  describe "GET /api/pleroma/accounts/mfa/confirm/totp" do
+    test "returns success result", %{conn: conn} do
+      secret = TOTP.generate_secret()
+      code = TOTP.generate_token(secret)
+
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %Settings{
+            backup_codes: ["1", "2", "3"],
+            totp: %Settings.TOTP{secret: secret}
+          }
+        )
+
+      token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
+      token2 = insert(:oauth_token, scopes: ["read"])
+
+      assert conn
+             |> put_req_header("authorization", "Bearer #{token.token}")
+             |> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "test", code: code})
+             |> json_response(:ok)
+
+      settings = refresh_record(user).multi_factor_authentication_settings
+      assert settings.enabled
+      assert settings.totp.secret == secret
+      assert settings.totp.confirmed
+      assert settings.backup_codes == ["1", "2", "3"]
+
+      assert conn
+             |> put_req_header("authorization", "Bearer #{token2.token}")
+             |> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "test", code: code})
+             |> json_response(403) == %{
+               "error" => "Insufficient permissions: write:security."
+             }
+    end
+
+    test "returns error if password incorrect", %{conn: conn} do
+      secret = TOTP.generate_secret()
+      code = TOTP.generate_token(secret)
+
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %Settings{
+            backup_codes: ["1", "2", "3"],
+            totp: %Settings.TOTP{secret: secret}
+          }
+        )
+
+      token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
+
+      response =
+        conn
+        |> put_req_header("authorization", "Bearer #{token.token}")
+        |> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "xxx", code: code})
+        |> json_response(422)
+
+      settings = refresh_record(user).multi_factor_authentication_settings
+      refute settings.enabled
+      refute settings.totp.confirmed
+      assert settings.backup_codes == ["1", "2", "3"]
+      assert response == %{"error" => "Invalid password."}
+    end
+
+    test "returns error if code incorrect", %{conn: conn} do
+      secret = TOTP.generate_secret()
+
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %Settings{
+            backup_codes: ["1", "2", "3"],
+            totp: %Settings.TOTP{secret: secret}
+          }
+        )
+
+      token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
+      token2 = insert(:oauth_token, scopes: ["read"])
+
+      response =
+        conn
+        |> put_req_header("authorization", "Bearer #{token.token}")
+        |> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "test", code: "code"})
+        |> json_response(422)
+
+      settings = refresh_record(user).multi_factor_authentication_settings
+      refute settings.enabled
+      refute settings.totp.confirmed
+      assert settings.backup_codes == ["1", "2", "3"]
+      assert response == %{"error" => "invalid_token"}
+
+      assert conn
+             |> put_req_header("authorization", "Bearer #{token2.token}")
+             |> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "test", code: "code"})
+             |> json_response(403) == %{
+               "error" => "Insufficient permissions: write:security."
+             }
+    end
+  end
+
+  describe "DELETE /api/pleroma/accounts/mfa/totp" do
+    test "returns success result", %{conn: conn} do
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %Settings{
+            backup_codes: ["1", "2", "3"],
+            totp: %Settings.TOTP{secret: "secret"}
+          }
+        )
+
+      token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
+      token2 = insert(:oauth_token, scopes: ["read"])
+
+      assert conn
+             |> put_req_header("authorization", "Bearer #{token.token}")
+             |> delete("/api/pleroma/accounts/mfa/totp", %{password: "test"})
+             |> json_response(:ok)
+
+      settings = refresh_record(user).multi_factor_authentication_settings
+      refute settings.enabled
+      assert settings.totp.secret == nil
+      refute settings.totp.confirmed
+
+      assert conn
+             |> put_req_header("authorization", "Bearer #{token2.token}")
+             |> delete("/api/pleroma/accounts/mfa/totp", %{password: "test"})
+             |> json_response(403) == %{
+               "error" => "Insufficient permissions: write:security."
+             }
+    end
+  end
+end
diff --git a/test/web/twitter_api/remote_follow_controller_test.exs b/test/web/twitter_api/remote_follow_controller_test.exs
index 5ff8694a8..f7e54c26a 100644
--- a/test/web/twitter_api/remote_follow_controller_test.exs
+++ b/test/web/twitter_api/remote_follow_controller_test.exs
@@ -6,11 +6,14 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowControllerTest do
   use Pleroma.Web.ConnCase
 
   alias Pleroma.Config
+  alias Pleroma.MFA
+  alias Pleroma.MFA.TOTP
   alias Pleroma.User
   alias Pleroma.Web.CommonAPI
 
   import ExUnit.CaptureLog
   import Pleroma.Factory
+  import Ecto.Query
 
   setup do
     Tesla.Mock.mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
@@ -160,6 +163,119 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowControllerTest do
     end
   end
 
+  describe "POST /ostatus_subscribe - follow/2 with enabled Two-Factor Auth " do
+    test "render the MFA login form", %{conn: conn} do
+      otp_secret = TOTP.generate_secret()
+
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %MFA.Settings{
+            enabled: true,
+            totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+          }
+        )
+
+      user2 = insert(:user)
+
+      response =
+        conn
+        |> post(remote_follow_path(conn, :do_follow), %{
+          "authorization" => %{"name" => user.nickname, "password" => "test", "id" => user2.id}
+        })
+        |> response(200)
+
+      mfa_token = Pleroma.Repo.one(from(q in Pleroma.MFA.Token, where: q.user_id == ^user.id))
+
+      assert response =~ "Two-factor authentication"
+      assert response =~ "Authentication code"
+      assert response =~ mfa_token.token
+      refute user2.follower_address in User.following(user)
+    end
+
+    test "returns error when password is incorrect", %{conn: conn} do
+      otp_secret = TOTP.generate_secret()
+
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %MFA.Settings{
+            enabled: true,
+            totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+          }
+        )
+
+      user2 = insert(:user)
+
+      response =
+        conn
+        |> post(remote_follow_path(conn, :do_follow), %{
+          "authorization" => %{"name" => user.nickname, "password" => "test1", "id" => user2.id}
+        })
+        |> response(200)
+
+      assert response =~ "Wrong username or password"
+      refute user2.follower_address in User.following(user)
+    end
+
+    test "follows", %{conn: conn} do
+      otp_secret = TOTP.generate_secret()
+
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %MFA.Settings{
+            enabled: true,
+            totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+          }
+        )
+
+      {:ok, %{token: token}} = MFA.Token.create_token(user)
+
+      user2 = insert(:user)
+      otp_token = TOTP.generate_token(otp_secret)
+
+      conn =
+        conn
+        |> post(
+          remote_follow_path(conn, :do_follow),
+          %{
+            "mfa" => %{"code" => otp_token, "token" => token, "id" => user2.id}
+          }
+        )
+
+      assert redirected_to(conn) == "/users/#{user2.id}"
+      assert user2.follower_address in User.following(user)
+    end
+
+    test "returns error when auth code is incorrect", %{conn: conn} do
+      otp_secret = TOTP.generate_secret()
+
+      user =
+        insert(:user,
+          multi_factor_authentication_settings: %MFA.Settings{
+            enabled: true,
+            totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+          }
+        )
+
+      {:ok, %{token: token}} = MFA.Token.create_token(user)
+
+      user2 = insert(:user)
+      otp_token = TOTP.generate_token(TOTP.generate_secret())
+
+      response =
+        conn
+        |> post(
+          remote_follow_path(conn, :do_follow),
+          %{
+            "mfa" => %{"code" => otp_token, "token" => token, "id" => user2.id}
+          }
+        )
+        |> response(200)
+
+      assert response =~ "Wrong authentication code"
+      refute user2.follower_address in User.following(user)
+    end
+  end
+
   describe "POST /ostatus_subscribe - follow/2 without assigned user " do
     test "follows", %{conn: conn} do
       user = insert(:user)

From 9491ba3e49450e80cd1c21358c01e4e06e3d881d Mon Sep 17 00:00:00 2001
From: href <href+git-pleroma@random.sh>
Date: Thu, 7 May 2020 09:13:32 +0000
Subject: [PATCH 75/76] Streamer rework

---
 lib/pleroma/application.ex                    |   9 +-
 lib/pleroma/web/activity_pub/activity_pub.ex  |  32 +-
 .../web/mastodon_api/websocket_handler.ex     |  47 +-
 lib/pleroma/web/streamer/ping.ex              |  37 --
 lib/pleroma/web/streamer/state.ex             |  82 ---
 lib/pleroma/web/streamer/streamer.ex          | 250 ++++++-
 lib/pleroma/web/streamer/streamer_socket.ex   |  35 -
 lib/pleroma/web/streamer/supervisor.ex        |  37 --
 lib/pleroma/web/streamer/worker.ex            | 208 ------
 test/integration/mastodon_websocket_test.exs  |   7 +-
 test/notification_test.exs                    |  18 +-
 test/support/builders/activity_builder.ex     |  10 +-
 test/support/conn_case.ex                     |   6 +-
 test/support/data_case.ex                     |   6 +-
 test/web/streamer/ping_test.exs               |  36 -
 test/web/streamer/state_test.exs              |  54 --
 test/web/streamer/streamer_test.exs           | 618 +++++++-----------
 17 files changed, 535 insertions(+), 957 deletions(-)
 delete mode 100644 lib/pleroma/web/streamer/ping.ex
 delete mode 100644 lib/pleroma/web/streamer/state.ex
 delete mode 100644 lib/pleroma/web/streamer/streamer_socket.ex
 delete mode 100644 lib/pleroma/web/streamer/supervisor.ex
 delete mode 100644 lib/pleroma/web/streamer/worker.ex
 delete mode 100644 test/web/streamer/ping_test.exs
 delete mode 100644 test/web/streamer/state_test.exs

diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex
index 308d8cffa..a00bc0624 100644
--- a/lib/pleroma/application.ex
+++ b/lib/pleroma/application.ex
@@ -173,7 +173,14 @@ defmodule Pleroma.Application do
   defp streamer_child(env) when env in [:test, :benchmark], do: []
 
   defp streamer_child(_) do
-    [Pleroma.Web.Streamer.supervisor()]
+    [
+      {Registry,
+       [
+         name: Pleroma.Web.Streamer.registry(),
+         keys: :duplicate,
+         partitions: System.schedulers_online()
+       ]}
+    ]
   end
 
   defp chat_child(_env, true) do
diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex
index 099df5879..8baaf97ac 100644
--- a/lib/pleroma/web/activity_pub/activity_pub.ex
+++ b/lib/pleroma/web/activity_pub/activity_pub.ex
@@ -170,12 +170,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
 
       BackgroundWorker.enqueue("fetch_data_for_activity", %{"activity_id" => activity.id})
 
-      Notification.create_notifications(activity)
-
-      conversation = create_or_bump_conversation(activity, map["actor"])
-      participations = get_participations(conversation)
-      stream_out(activity)
-      stream_out_participations(participations)
       {:ok, activity}
     else
       %Activity{} = activity ->
@@ -198,6 +192,15 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     end
   end
 
+  def notify_and_stream(activity) do
+    Notification.create_notifications(activity)
+
+    conversation = create_or_bump_conversation(activity, activity.actor)
+    participations = get_participations(conversation)
+    stream_out(activity)
+    stream_out_participations(participations)
+  end
+
   defp create_or_bump_conversation(activity, actor) do
     with {:ok, conversation} <- Conversation.create_or_bump_for(activity),
          %User{} = user <- User.get_cached_by_ap_id(actor),
@@ -274,6 +277,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
          _ <- increase_poll_votes_if_vote(create_data),
          {:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity},
          {:ok, _actor} <- increase_note_count_if_public(actor, activity),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity}
     else
@@ -301,6 +305,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
              additional
            ),
          {:ok, activity} <- insert(listen_data, local),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity}
     end
@@ -325,6 +330,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
            %{"to" => to, "type" => type, "actor" => actor.ap_id, "object" => object}
            |> Utils.maybe_put("id", activity_id),
          {:ok, activity} <- insert(data, local),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity}
     end
@@ -344,6 +350,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
          },
          data <- Utils.maybe_put(data, "id", activity_id),
          {:ok, activity} <- insert(data, local),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity}
     end
@@ -365,6 +372,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
          reaction_data <- make_emoji_reaction_data(user, object, emoji, activity_id),
          {:ok, activity} <- insert(reaction_data, local),
          {:ok, object} <- add_emoji_reaction_to_object(activity, object),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity, object}
     else
@@ -391,6 +399,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
          unreact_data <- make_undo_data(user, reaction_activity, activity_id),
          {:ok, activity} <- insert(unreact_data, local),
          {:ok, object} <- remove_emoji_reaction_from_object(reaction_activity, object),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity, object}
     else
@@ -413,6 +422,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
          {:ok, unlike_activity} <- insert(unlike_data, local),
          {:ok, _activity} <- Repo.delete(like_activity),
          {:ok, object} <- remove_like_from_object(like_activity, object),
+         _ <- notify_and_stream(unlike_activity),
          :ok <- maybe_federate(unlike_activity) do
       {:ok, unlike_activity, like_activity, object}
     else
@@ -442,6 +452,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
          announce_data <- make_announce_data(user, object, activity_id, public),
          {:ok, activity} <- insert(announce_data, local),
          {:ok, object} <- add_announce_to_object(activity, object),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity, object}
     else
@@ -468,6 +479,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     with %Activity{} = announce_activity <- get_existing_announce(actor.ap_id, object),
          unannounce_data <- make_unannounce_data(actor, announce_activity, activity_id),
          {:ok, unannounce_activity} <- insert(unannounce_data, local),
+         _ <- notify_and_stream(unannounce_activity),
          :ok <- maybe_federate(unannounce_activity),
          {:ok, _activity} <- Repo.delete(announce_activity),
          {:ok, object} <- remove_announce_from_object(announce_activity, object) do
@@ -490,6 +502,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
   defp do_follow(follower, followed, activity_id, local) do
     with data <- make_follow_data(follower, followed, activity_id),
          {:ok, activity} <- insert(data, local),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity}
     else
@@ -511,6 +524,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
          {:ok, follow_activity} <- update_follow_state(follow_activity, "cancelled"),
          unfollow_data <- make_unfollow_data(follower, followed, follow_activity, activity_id),
          {:ok, activity} <- insert(unfollow_data, local),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity}
     else
@@ -540,6 +554,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     with true <- outgoing_blocks,
          block_data <- make_block_data(blocker, blocked, activity_id),
          {:ok, activity} <- insert(block_data, local),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity}
     else
@@ -560,6 +575,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     with %Activity{} = block_activity <- fetch_latest_block(blocker, blocked),
          unblock_data <- make_unblock_data(blocker, blocked, block_activity, activity_id),
          {:ok, activity} <- insert(unblock_data, local),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity}
     else
@@ -594,6 +610,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     with flag_data <- make_flag_data(params, additional),
          {:ok, activity} <- insert(flag_data, local),
          {:ok, stripped_activity} <- strip_report_status_data(activity),
+         _ <- notify_and_stream(activity),
          :ok <- maybe_federate(stripped_activity) do
       User.all_superusers()
       |> Enum.filter(fn user -> not is_nil(user.email) end)
@@ -617,7 +634,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     }
 
     with true <- origin.ap_id in target.also_known_as,
-         {:ok, activity} <- insert(params, local) do
+         {:ok, activity} <- insert(params, local),
+         _ <- notify_and_stream(activity) do
       maybe_federate(activity)
 
       BackgroundWorker.enqueue("move_following", %{
diff --git a/lib/pleroma/web/mastodon_api/websocket_handler.ex b/lib/pleroma/web/mastodon_api/websocket_handler.ex
index 5652a37c1..6ef3fe2dd 100644
--- a/lib/pleroma/web/mastodon_api/websocket_handler.ex
+++ b/lib/pleroma/web/mastodon_api/websocket_handler.ex
@@ -12,6 +12,11 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
 
   @behaviour :cowboy_websocket
 
+  # Cowboy timeout period.
+  @timeout :timer.seconds(30)
+  # Hibernate every X messages
+  @hibernate_every 100
+
   @streams [
     "public",
     "public:local",
@@ -25,9 +30,6 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
   ]
   @anonymous_streams ["public", "public:local", "hashtag"]
 
-  # Handled by periodic keepalive in Pleroma.Web.Streamer.Ping.
-  @timeout :infinity
-
   def init(%{qs: qs} = req, state) do
     with params <- :cow_qs.parse_qs(qs),
          sec_websocket <- :cowboy_req.header("sec-websocket-protocol", req, nil),
@@ -42,7 +44,7 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
           req
         end
 
-      {:cowboy_websocket, req, %{user: user, topic: topic}, %{idle_timeout: @timeout}}
+      {:cowboy_websocket, req, %{user: user, topic: topic, count: 0}, %{idle_timeout: @timeout}}
     else
       {:error, code} ->
         Logger.debug("#{__MODULE__} denied connection: #{inspect(code)} - #{inspect(req)}")
@@ -57,7 +59,13 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
   end
 
   def websocket_init(state) do
-    send(self(), :subscribe)
+    Logger.debug(
+      "#{__MODULE__} accepted websocket connection for user #{
+        (state.user || %{id: "anonymous"}).id
+      }, topic #{state.topic}"
+    )
+
+    Streamer.add_socket(state.topic, state.user)
     {:ok, state}
   end
 
@@ -66,19 +74,24 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
     {:ok, state}
   end
 
-  def websocket_info(:subscribe, state) do
-    Logger.debug(
-      "#{__MODULE__} accepted websocket connection for user #{
-        (state.user || %{id: "anonymous"}).id
-      }, topic #{state.topic}"
-    )
+  def websocket_info({:render_with_user, view, template, item}, state) do
+    user = %User{} = User.get_cached_by_ap_id(state.user.ap_id)
 
-    Streamer.add_socket(state.topic, streamer_socket(state))
-    {:ok, state}
+    unless Streamer.filtered_by_user?(user, item) do
+      websocket_info({:text, view.render(template, user, item)}, %{state | user: user})
+    else
+      {:ok, state}
+    end
   end
 
   def websocket_info({:text, message}, state) do
-    {:reply, {:text, message}, state}
+    # If the websocket processed X messages, force an hibernate/GC.
+    # We don't hibernate at every message to balance CPU usage/latency with RAM usage.
+    if state.count > @hibernate_every do
+      {:reply, {:text, message}, %{state | count: 0}, :hibernate}
+    else
+      {:reply, {:text, message}, %{state | count: state.count + 1}}
+    end
   end
 
   def terminate(reason, _req, state) do
@@ -88,7 +101,7 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
       }, topic #{state.topic || "?"}: #{inspect(reason)}"
     )
 
-    Streamer.remove_socket(state.topic, streamer_socket(state))
+    Streamer.remove_socket(state.topic)
     :ok
   end
 
@@ -136,8 +149,4 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
   end
 
   defp expand_topic(topic, _), do: topic
-
-  defp streamer_socket(state) do
-    %{transport_pid: self(), assigns: state}
-  end
 end
diff --git a/lib/pleroma/web/streamer/ping.ex b/lib/pleroma/web/streamer/ping.ex
deleted file mode 100644
index 7a08202a9..000000000
--- a/lib/pleroma/web/streamer/ping.ex
+++ /dev/null
@@ -1,37 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.Streamer.Ping do
-  use GenServer
-  require Logger
-
-  alias Pleroma.Web.Streamer.State
-  alias Pleroma.Web.Streamer.StreamerSocket
-
-  @keepalive_interval :timer.seconds(30)
-
-  def start_link(opts) do
-    ping_interval = Keyword.get(opts, :ping_interval, @keepalive_interval)
-    GenServer.start_link(__MODULE__, %{ping_interval: ping_interval}, name: __MODULE__)
-  end
-
-  def init(%{ping_interval: ping_interval} = args) do
-    Process.send_after(self(), :ping, ping_interval)
-    {:ok, args}
-  end
-
-  def handle_info(:ping, %{ping_interval: ping_interval} = state) do
-    State.get_sockets()
-    |> Map.values()
-    |> List.flatten()
-    |> Enum.each(fn %StreamerSocket{transport_pid: transport_pid} ->
-      Logger.debug("Sending keepalive ping")
-      send(transport_pid, {:text, ""})
-    end)
-
-    Process.send_after(self(), :ping, ping_interval)
-
-    {:noreply, state}
-  end
-end
diff --git a/lib/pleroma/web/streamer/state.ex b/lib/pleroma/web/streamer/state.ex
deleted file mode 100644
index 999550b88..000000000
--- a/lib/pleroma/web/streamer/state.ex
+++ /dev/null
@@ -1,82 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.Streamer.State do
-  use GenServer
-  require Logger
-
-  alias Pleroma.Web.Streamer.StreamerSocket
-
-  @env Mix.env()
-
-  def start_link(_) do
-    GenServer.start_link(__MODULE__, %{sockets: %{}}, name: __MODULE__)
-  end
-
-  def add_socket(topic, socket) do
-    GenServer.call(__MODULE__, {:add, topic, socket})
-  end
-
-  def remove_socket(topic, socket) do
-    do_remove_socket(@env, topic, socket)
-  end
-
-  def get_sockets do
-    %{sockets: stream_sockets} = GenServer.call(__MODULE__, :get_state)
-    stream_sockets
-  end
-
-  def init(init_arg) do
-    {:ok, init_arg}
-  end
-
-  def handle_call(:get_state, _from, state) do
-    {:reply, state, state}
-  end
-
-  def handle_call({:add, topic, socket}, _from, %{sockets: sockets} = state) do
-    internal_topic = internal_topic(topic, socket)
-    stream_socket = StreamerSocket.from_socket(socket)
-
-    sockets_for_topic =
-      sockets
-      |> Map.get(internal_topic, [])
-      |> List.insert_at(0, stream_socket)
-      |> Enum.uniq()
-
-    state = put_in(state, [:sockets, internal_topic], sockets_for_topic)
-    Logger.debug("Got new conn for #{topic}")
-    {:reply, state, state}
-  end
-
-  def handle_call({:remove, topic, socket}, _from, %{sockets: sockets} = state) do
-    internal_topic = internal_topic(topic, socket)
-    stream_socket = StreamerSocket.from_socket(socket)
-
-    sockets_for_topic =
-      sockets
-      |> Map.get(internal_topic, [])
-      |> List.delete(stream_socket)
-
-    state = Kernel.put_in(state, [:sockets, internal_topic], sockets_for_topic)
-    {:reply, state, state}
-  end
-
-  defp do_remove_socket(:test, _, _) do
-    :ok
-  end
-
-  defp do_remove_socket(_env, topic, socket) do
-    GenServer.call(__MODULE__, {:remove, topic, socket})
-  end
-
-  defp internal_topic(topic, socket)
-       when topic in ~w[user user:notification direct] do
-    "#{topic}:#{socket.assigns[:user].id}"
-  end
-
-  defp internal_topic(topic, _) do
-    topic
-  end
-end
diff --git a/lib/pleroma/web/streamer/streamer.ex b/lib/pleroma/web/streamer/streamer.ex
index 814d5a729..5ad4aa936 100644
--- a/lib/pleroma/web/streamer/streamer.ex
+++ b/lib/pleroma/web/streamer/streamer.ex
@@ -3,53 +3,241 @@
 # SPDX-License-Identifier: AGPL-3.0-only
 
 defmodule Pleroma.Web.Streamer do
-  alias Pleroma.Web.Streamer.State
-  alias Pleroma.Web.Streamer.Worker
+  require Logger
+
+  alias Pleroma.Activity
+  alias Pleroma.Config
+  alias Pleroma.Conversation.Participation
+  alias Pleroma.Notification
+  alias Pleroma.Object
+  alias Pleroma.User
+  alias Pleroma.Web.ActivityPub.ActivityPub
+  alias Pleroma.Web.ActivityPub.Visibility
+  alias Pleroma.Web.CommonAPI
+  alias Pleroma.Web.StreamerView
 
-  @timeout 60_000
   @mix_env Mix.env()
+  @registry Pleroma.Web.StreamerRegistry
 
-  def add_socket(topic, socket) do
-    State.add_socket(topic, socket)
+  def registry, do: @registry
+
+  def add_socket(topic, %User{} = user) do
+    if should_env_send?(), do: Registry.register(@registry, user_topic(topic, user), true)
   end
 
-  def remove_socket(topic, socket) do
-    State.remove_socket(topic, socket)
+  def add_socket(topic, _) do
+    if should_env_send?(), do: Registry.register(@registry, topic, false)
   end
 
-  def get_sockets do
-    State.get_sockets()
+  def remove_socket(topic) do
+    if should_env_send?(), do: Registry.unregister(@registry, topic)
   end
 
-  def stream(topics, items) do
-    if should_send?() do
-      Task.async(fn ->
-        :poolboy.transaction(
-          :streamer_worker,
-          &Worker.stream(&1, topics, items),
-          @timeout
-        )
+  def stream(topics, item) when is_list(topics) do
+    if should_env_send?() do
+      Enum.each(topics, fn t ->
+        spawn(fn -> do_stream(t, item) end)
       end)
     end
+
+    :ok
   end
 
-  def supervisor, do: Pleroma.Web.Streamer.Supervisor
+  def stream(topic, items) when is_list(items) do
+    if should_env_send?() do
+      Enum.each(items, fn i ->
+        spawn(fn -> do_stream(topic, i) end)
+      end)
 
-  defp should_send? do
-    handle_should_send(@mix_env)
-  end
-
-  defp handle_should_send(:test) do
-    case Process.whereis(:streamer_worker) do
-      nil ->
-        false
-
-      pid ->
-        Process.alive?(pid)
+      :ok
     end
   end
 
-  defp handle_should_send(:benchmark), do: false
+  def stream(topic, item) do
+    if should_env_send?() do
+      spawn(fn -> do_stream(topic, item) end)
+    end
 
-  defp handle_should_send(_), do: true
+    :ok
+  end
+
+  def filtered_by_user?(%User{} = user, %Activity{} = item) do
+    %{block: blocked_ap_ids, mute: muted_ap_ids, reblog_mute: reblog_muted_ap_ids} =
+      User.outgoing_relationships_ap_ids(user, [:block, :mute, :reblog_mute])
+
+    recipient_blocks = MapSet.new(blocked_ap_ids ++ muted_ap_ids)
+    recipients = MapSet.new(item.recipients)
+    domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks)
+
+    with parent <- Object.normalize(item) || item,
+         true <-
+           Enum.all?([blocked_ap_ids, muted_ap_ids], &(item.actor not in &1)),
+         true <- item.data["type"] != "Announce" || item.actor not in reblog_muted_ap_ids,
+         true <- Enum.all?([blocked_ap_ids, muted_ap_ids], &(parent.data["actor"] not in &1)),
+         true <- MapSet.disjoint?(recipients, recipient_blocks),
+         %{host: item_host} <- URI.parse(item.actor),
+         %{host: parent_host} <- URI.parse(parent.data["actor"]),
+         false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, item_host),
+         false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, parent_host),
+         true <- thread_containment(item, user),
+         false <- CommonAPI.thread_muted?(user, item) do
+      false
+    else
+      _ -> true
+    end
+  end
+
+  def filtered_by_user?(%User{} = user, %Notification{activity: activity}) do
+    filtered_by_user?(user, activity)
+  end
+
+  defp do_stream("direct", item) do
+    recipient_topics =
+      User.get_recipients_from_activity(item)
+      |> Enum.map(fn %{id: id} -> "direct:#{id}" end)
+
+    Enum.each(recipient_topics, fn user_topic ->
+      Logger.debug("Trying to push direct message to #{user_topic}\n\n")
+      push_to_socket(user_topic, item)
+    end)
+  end
+
+  defp do_stream("participation", participation) do
+    user_topic = "direct:#{participation.user_id}"
+    Logger.debug("Trying to push a conversation participation to #{user_topic}\n\n")
+
+    push_to_socket(user_topic, participation)
+  end
+
+  defp do_stream("list", item) do
+    # filter the recipient list if the activity is not public, see #270.
+    recipient_lists =
+      case Visibility.is_public?(item) do
+        true ->
+          Pleroma.List.get_lists_from_activity(item)
+
+        _ ->
+          Pleroma.List.get_lists_from_activity(item)
+          |> Enum.filter(fn list ->
+            owner = User.get_cached_by_id(list.user_id)
+
+            Visibility.visible_for_user?(item, owner)
+          end)
+      end
+
+    recipient_topics =
+      recipient_lists
+      |> Enum.map(fn %{id: id} -> "list:#{id}" end)
+
+    Enum.each(recipient_topics, fn list_topic ->
+      Logger.debug("Trying to push message to #{list_topic}\n\n")
+      push_to_socket(list_topic, item)
+    end)
+  end
+
+  defp do_stream(topic, %Notification{} = item)
+       when topic in ["user", "user:notification"] do
+    Registry.dispatch(@registry, "#{topic}:#{item.user_id}", fn list ->
+      Enum.each(list, fn {pid, _auth} ->
+        send(pid, {:render_with_user, StreamerView, "notification.json", item})
+      end)
+    end)
+  end
+
+  defp do_stream("user", item) do
+    Logger.debug("Trying to push to users")
+
+    recipient_topics =
+      User.get_recipients_from_activity(item)
+      |> Enum.map(fn %{id: id} -> "user:#{id}" end)
+
+    Enum.each(recipient_topics, fn topic ->
+      push_to_socket(topic, item)
+    end)
+  end
+
+  defp do_stream(topic, item) do
+    Logger.debug("Trying to push to #{topic}")
+    Logger.debug("Pushing item to #{topic}")
+    push_to_socket(topic, item)
+  end
+
+  defp push_to_socket(topic, %Participation{} = participation) do
+    rendered = StreamerView.render("conversation.json", participation)
+
+    Registry.dispatch(@registry, topic, fn list ->
+      Enum.each(list, fn {pid, _} ->
+        send(pid, {:text, rendered})
+      end)
+    end)
+  end
+
+  defp push_to_socket(topic, %Activity{
+         data: %{"type" => "Delete", "deleted_activity_id" => deleted_activity_id}
+       }) do
+    rendered = Jason.encode!(%{event: "delete", payload: to_string(deleted_activity_id)})
+
+    Registry.dispatch(@registry, topic, fn list ->
+      Enum.each(list, fn {pid, _} ->
+        send(pid, {:text, rendered})
+      end)
+    end)
+  end
+
+  defp push_to_socket(_topic, %Activity{data: %{"type" => "Delete"}}), do: :noop
+
+  defp push_to_socket(topic, item) do
+    anon_render = StreamerView.render("update.json", item)
+
+    Registry.dispatch(@registry, topic, fn list ->
+      Enum.each(list, fn {pid, auth?} ->
+        if auth? do
+          send(pid, {:render_with_user, StreamerView, "update.json", item})
+        else
+          send(pid, {:text, anon_render})
+        end
+      end)
+    end)
+  end
+
+  defp thread_containment(_activity, %User{skip_thread_containment: true}), do: true
+
+  defp thread_containment(activity, user) do
+    if Config.get([:instance, :skip_thread_containment]) do
+      true
+    else
+      ActivityPub.contain_activity(activity, user)
+    end
+  end
+
+  # In test environement, only return true if the registry is started.
+  # In benchmark environment, returns false.
+  # In any other environment, always returns true.
+  cond do
+    @mix_env == :test ->
+      def should_env_send? do
+        case Process.whereis(@registry) do
+          nil ->
+            false
+
+          pid ->
+            Process.alive?(pid)
+        end
+      end
+
+    @mix_env == :benchmark ->
+      def should_env_send?, do: false
+
+    true ->
+      def should_env_send?, do: true
+  end
+
+  defp user_topic(topic, user)
+       when topic in ~w[user user:notification direct] do
+    "#{topic}:#{user.id}"
+  end
+
+  defp user_topic(topic, _) do
+    topic
+  end
 end
diff --git a/lib/pleroma/web/streamer/streamer_socket.ex b/lib/pleroma/web/streamer/streamer_socket.ex
deleted file mode 100644
index 7d5dcd34e..000000000
--- a/lib/pleroma/web/streamer/streamer_socket.ex
+++ /dev/null
@@ -1,35 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.Streamer.StreamerSocket do
-  defstruct transport_pid: nil, user: nil
-
-  alias Pleroma.User
-  alias Pleroma.Web.Streamer.StreamerSocket
-
-  def from_socket(%{
-        transport_pid: transport_pid,
-        assigns: %{user: nil}
-      }) do
-    %StreamerSocket{
-      transport_pid: transport_pid
-    }
-  end
-
-  def from_socket(%{
-        transport_pid: transport_pid,
-        assigns: %{user: %User{} = user}
-      }) do
-    %StreamerSocket{
-      transport_pid: transport_pid,
-      user: user
-    }
-  end
-
-  def from_socket(%{transport_pid: transport_pid}) do
-    %StreamerSocket{
-      transport_pid: transport_pid
-    }
-  end
-end
diff --git a/lib/pleroma/web/streamer/supervisor.ex b/lib/pleroma/web/streamer/supervisor.ex
deleted file mode 100644
index bd9029bc0..000000000
--- a/lib/pleroma/web/streamer/supervisor.ex
+++ /dev/null
@@ -1,37 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.Streamer.Supervisor do
-  use Supervisor
-
-  def start_link(opts) do
-    Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
-  end
-
-  def init(args) do
-    children = [
-      {Pleroma.Web.Streamer.State, args},
-      {Pleroma.Web.Streamer.Ping, args},
-      :poolboy.child_spec(:streamer_worker, poolboy_config())
-    ]
-
-    opts = [strategy: :one_for_one, name: Pleroma.Web.Streamer.Supervisor]
-    Supervisor.init(children, opts)
-  end
-
-  defp poolboy_config do
-    opts =
-      Pleroma.Config.get(:streamer,
-        workers: 3,
-        overflow_workers: 2
-      )
-
-    [
-      {:name, {:local, :streamer_worker}},
-      {:worker_module, Pleroma.Web.Streamer.Worker},
-      {:size, opts[:workers]},
-      {:max_overflow, opts[:overflow_workers]}
-    ]
-  end
-end
diff --git a/lib/pleroma/web/streamer/worker.ex b/lib/pleroma/web/streamer/worker.ex
deleted file mode 100644
index f6160fa4d..000000000
--- a/lib/pleroma/web/streamer/worker.ex
+++ /dev/null
@@ -1,208 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.Streamer.Worker do
-  use GenServer
-
-  require Logger
-
-  alias Pleroma.Activity
-  alias Pleroma.Config
-  alias Pleroma.Conversation.Participation
-  alias Pleroma.Notification
-  alias Pleroma.Object
-  alias Pleroma.User
-  alias Pleroma.Web.ActivityPub.ActivityPub
-  alias Pleroma.Web.ActivityPub.Visibility
-  alias Pleroma.Web.CommonAPI
-  alias Pleroma.Web.Streamer.State
-  alias Pleroma.Web.Streamer.StreamerSocket
-  alias Pleroma.Web.StreamerView
-
-  def start_link(_) do
-    GenServer.start_link(__MODULE__, %{}, [])
-  end
-
-  def init(init_arg) do
-    {:ok, init_arg}
-  end
-
-  def stream(pid, topics, items) do
-    GenServer.call(pid, {:stream, topics, items})
-  end
-
-  def handle_call({:stream, topics, item}, _from, state) when is_list(topics) do
-    Enum.each(topics, fn t ->
-      do_stream(%{topic: t, item: item})
-    end)
-
-    {:reply, state, state}
-  end
-
-  def handle_call({:stream, topic, items}, _from, state) when is_list(items) do
-    Enum.each(items, fn i ->
-      do_stream(%{topic: topic, item: i})
-    end)
-
-    {:reply, state, state}
-  end
-
-  def handle_call({:stream, topic, item}, _from, state) do
-    do_stream(%{topic: topic, item: item})
-
-    {:reply, state, state}
-  end
-
-  defp do_stream(%{topic: "direct", item: item}) do
-    recipient_topics =
-      User.get_recipients_from_activity(item)
-      |> Enum.map(fn %{id: id} -> "direct:#{id}" end)
-
-    Enum.each(recipient_topics, fn user_topic ->
-      Logger.debug("Trying to push direct message to #{user_topic}\n\n")
-      push_to_socket(State.get_sockets(), user_topic, item)
-    end)
-  end
-
-  defp do_stream(%{topic: "participation", item: participation}) do
-    user_topic = "direct:#{participation.user_id}"
-    Logger.debug("Trying to push a conversation participation to #{user_topic}\n\n")
-
-    push_to_socket(State.get_sockets(), user_topic, participation)
-  end
-
-  defp do_stream(%{topic: "list", item: item}) do
-    # filter the recipient list if the activity is not public, see #270.
-    recipient_lists =
-      case Visibility.is_public?(item) do
-        true ->
-          Pleroma.List.get_lists_from_activity(item)
-
-        _ ->
-          Pleroma.List.get_lists_from_activity(item)
-          |> Enum.filter(fn list ->
-            owner = User.get_cached_by_id(list.user_id)
-
-            Visibility.visible_for_user?(item, owner)
-          end)
-      end
-
-    recipient_topics =
-      recipient_lists
-      |> Enum.map(fn %{id: id} -> "list:#{id}" end)
-
-    Enum.each(recipient_topics, fn list_topic ->
-      Logger.debug("Trying to push message to #{list_topic}\n\n")
-      push_to_socket(State.get_sockets(), list_topic, item)
-    end)
-  end
-
-  defp do_stream(%{topic: topic, item: %Notification{} = item})
-       when topic in ["user", "user:notification"] do
-    State.get_sockets()
-    |> Map.get("#{topic}:#{item.user_id}", [])
-    |> Enum.each(fn %StreamerSocket{transport_pid: transport_pid, user: socket_user} ->
-      with %User{} = user <- User.get_cached_by_ap_id(socket_user.ap_id),
-           true <- should_send?(user, item) do
-        send(transport_pid, {:text, StreamerView.render("notification.json", socket_user, item)})
-      end
-    end)
-  end
-
-  defp do_stream(%{topic: "user", item: item}) do
-    Logger.debug("Trying to push to users")
-
-    recipient_topics =
-      User.get_recipients_from_activity(item)
-      |> Enum.map(fn %{id: id} -> "user:#{id}" end)
-
-    Enum.each(recipient_topics, fn topic ->
-      push_to_socket(State.get_sockets(), topic, item)
-    end)
-  end
-
-  defp do_stream(%{topic: topic, item: item}) do
-    Logger.debug("Trying to push to #{topic}")
-    Logger.debug("Pushing item to #{topic}")
-    push_to_socket(State.get_sockets(), topic, item)
-  end
-
-  defp should_send?(%User{} = user, %Activity{} = item) do
-    %{block: blocked_ap_ids, mute: muted_ap_ids, reblog_mute: reblog_muted_ap_ids} =
-      User.outgoing_relationships_ap_ids(user, [:block, :mute, :reblog_mute])
-
-    recipient_blocks = MapSet.new(blocked_ap_ids ++ muted_ap_ids)
-    recipients = MapSet.new(item.recipients)
-    domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks)
-
-    with parent <- Object.normalize(item) || item,
-         true <-
-           Enum.all?([blocked_ap_ids, muted_ap_ids], &(item.actor not in &1)),
-         true <- item.data["type"] != "Announce" || item.actor not in reblog_muted_ap_ids,
-         true <- Enum.all?([blocked_ap_ids, muted_ap_ids], &(parent.data["actor"] not in &1)),
-         true <- MapSet.disjoint?(recipients, recipient_blocks),
-         %{host: item_host} <- URI.parse(item.actor),
-         %{host: parent_host} <- URI.parse(parent.data["actor"]),
-         false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, item_host),
-         false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, parent_host),
-         true <- thread_containment(item, user),
-         false <- CommonAPI.thread_muted?(user, item) do
-      true
-    else
-      _ -> false
-    end
-  end
-
-  defp should_send?(%User{} = user, %Notification{activity: activity}) do
-    should_send?(user, activity)
-  end
-
-  def push_to_socket(topics, topic, %Participation{} = participation) do
-    Enum.each(topics[topic] || [], fn %StreamerSocket{transport_pid: transport_pid} ->
-      send(transport_pid, {:text, StreamerView.render("conversation.json", participation)})
-    end)
-  end
-
-  def push_to_socket(topics, topic, %Activity{
-        data: %{"type" => "Delete", "deleted_activity_id" => deleted_activity_id}
-      }) do
-    Enum.each(topics[topic] || [], fn %StreamerSocket{transport_pid: transport_pid} ->
-      send(
-        transport_pid,
-        {:text, %{event: "delete", payload: to_string(deleted_activity_id)} |> Jason.encode!()}
-      )
-    end)
-  end
-
-  def push_to_socket(_topics, _topic, %Activity{data: %{"type" => "Delete"}}), do: :noop
-
-  def push_to_socket(topics, topic, item) do
-    Enum.each(topics[topic] || [], fn %StreamerSocket{
-                                        transport_pid: transport_pid,
-                                        user: socket_user
-                                      } ->
-      # Get the current user so we have up-to-date blocks etc.
-      if socket_user do
-        user = User.get_cached_by_ap_id(socket_user.ap_id)
-
-        if should_send?(user, item) do
-          send(transport_pid, {:text, StreamerView.render("update.json", item, user)})
-        end
-      else
-        send(transport_pid, {:text, StreamerView.render("update.json", item)})
-      end
-    end)
-  end
-
-  @spec thread_containment(Activity.t(), User.t()) :: boolean()
-  defp thread_containment(_activity, %User{skip_thread_containment: true}), do: true
-
-  defp thread_containment(activity, user) do
-    if Config.get([:instance, :skip_thread_containment]) do
-      true
-    else
-      ActivityPub.contain_activity(activity, user)
-    end
-  end
-end
diff --git a/test/integration/mastodon_websocket_test.exs b/test/integration/mastodon_websocket_test.exs
index bd229c55f..109c7b4cb 100644
--- a/test/integration/mastodon_websocket_test.exs
+++ b/test/integration/mastodon_websocket_test.exs
@@ -12,17 +12,14 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
   alias Pleroma.Web.CommonAPI
   alias Pleroma.Web.OAuth
 
+  @moduletag needs_streamer: true, capture_log: true
+
   @path Pleroma.Web.Endpoint.url()
         |> URI.parse()
         |> Map.put(:scheme, "ws")
         |> Map.put(:path, "/api/v1/streaming")
         |> URI.to_string()
 
-  setup_all do
-    start_supervised(Pleroma.Web.Streamer.supervisor())
-    :ok
-  end
-
   def start_socket(qs \\ nil, headers \\ []) do
     path =
       case qs do
diff --git a/test/notification_test.exs b/test/notification_test.exs
index 601a6c0ca..5c85f3368 100644
--- a/test/notification_test.exs
+++ b/test/notification_test.exs
@@ -162,14 +162,18 @@ defmodule Pleroma.NotificationTest do
     @tag needs_streamer: true
     test "it creates a notification for user and send to the 'user' and the 'user:notification' stream" do
       user = insert(:user)
-      task = Task.async(fn -> assert_receive {:text, _}, 4_000 end)
-      task_user_notification = Task.async(fn -> assert_receive {:text, _}, 4_000 end)
-      Streamer.add_socket("user", %{transport_pid: task.pid, assigns: %{user: user}})
 
-      Streamer.add_socket(
-        "user:notification",
-        %{transport_pid: task_user_notification.pid, assigns: %{user: user}}
-      )
+      task =
+        Task.async(fn ->
+          Streamer.add_socket("user", user)
+          assert_receive {:render_with_user, _, _, _}, 4_000
+        end)
+
+      task_user_notification =
+        Task.async(fn ->
+          Streamer.add_socket("user:notification", user)
+          assert_receive {:render_with_user, _, _, _}, 4_000
+        end)
 
       activity = insert(:note_activity)
 
diff --git a/test/support/builders/activity_builder.ex b/test/support/builders/activity_builder.ex
index 6e5a8e059..7c4950bfa 100644
--- a/test/support/builders/activity_builder.ex
+++ b/test/support/builders/activity_builder.ex
@@ -21,7 +21,15 @@ defmodule Pleroma.Builders.ActivityBuilder do
 
   def insert(data \\ %{}, opts \\ %{}) do
     activity = build(data, opts)
-    ActivityPub.insert(activity)
+
+    case ActivityPub.insert(activity) do
+      ok = {:ok, activity} ->
+        ActivityPub.notify_and_stream(activity)
+        ok
+
+      error ->
+        error
+    end
   end
 
   def insert_list(times, data \\ %{}, opts \\ %{}) do
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex
index 91c03b1a8..b23918dd1 100644
--- a/test/support/conn_case.ex
+++ b/test/support/conn_case.ex
@@ -139,7 +139,11 @@ defmodule Pleroma.Web.ConnCase do
     end
 
     if tags[:needs_streamer] do
-      start_supervised(Pleroma.Web.Streamer.supervisor())
+      start_supervised(%{
+        id: Pleroma.Web.Streamer.registry(),
+        start:
+          {Registry, :start_link, [[keys: :duplicate, name: Pleroma.Web.Streamer.registry()]]}
+      })
     end
 
     {:ok, conn: Phoenix.ConnTest.build_conn()}
diff --git a/test/support/data_case.ex b/test/support/data_case.ex
index 1669f2520..ba8848952 100644
--- a/test/support/data_case.ex
+++ b/test/support/data_case.ex
@@ -40,7 +40,11 @@ defmodule Pleroma.DataCase do
     end
 
     if tags[:needs_streamer] do
-      start_supervised(Pleroma.Web.Streamer.supervisor())
+      start_supervised(%{
+        id: Pleroma.Web.Streamer.registry(),
+        start:
+          {Registry, :start_link, [[keys: :duplicate, name: Pleroma.Web.Streamer.registry()]]}
+      })
     end
 
     :ok
diff --git a/test/web/streamer/ping_test.exs b/test/web/streamer/ping_test.exs
deleted file mode 100644
index 5df6c1cc3..000000000
--- a/test/web/streamer/ping_test.exs
+++ /dev/null
@@ -1,36 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.PingTest do
-  use Pleroma.DataCase
-
-  import Pleroma.Factory
-  alias Pleroma.Web.Streamer
-
-  setup do
-    start_supervised({Streamer.supervisor(), [ping_interval: 30]})
-
-    :ok
-  end
-
-  describe "sockets" do
-    setup do
-      user = insert(:user)
-      {:ok, %{user: user}}
-    end
-
-    test "it sends pings", %{user: user} do
-      task =
-        Task.async(fn ->
-          assert_receive {:text, received_event}, 40
-          assert_receive {:text, received_event}, 40
-          assert_receive {:text, received_event}, 40
-        end)
-
-      Streamer.add_socket("public", %{transport_pid: task.pid, assigns: %{user: user}})
-
-      Task.await(task)
-    end
-  end
-end
diff --git a/test/web/streamer/state_test.exs b/test/web/streamer/state_test.exs
deleted file mode 100644
index a755e75c0..000000000
--- a/test/web/streamer/state_test.exs
+++ /dev/null
@@ -1,54 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.StateTest do
-  use Pleroma.DataCase
-
-  import Pleroma.Factory
-  alias Pleroma.Web.Streamer
-  alias Pleroma.Web.Streamer.StreamerSocket
-
-  @moduletag needs_streamer: true
-
-  describe "sockets" do
-    setup do
-      user = insert(:user)
-      user2 = insert(:user)
-      {:ok, %{user: user, user2: user2}}
-    end
-
-    test "it can add a socket", %{user: user} do
-      Streamer.add_socket("public", %{transport_pid: 1, assigns: %{user: user}})
-
-      assert(%{"public" => [%StreamerSocket{transport_pid: 1}]} = Streamer.get_sockets())
-    end
-
-    test "it can add multiple sockets per user", %{user: user} do
-      Streamer.add_socket("public", %{transport_pid: 1, assigns: %{user: user}})
-      Streamer.add_socket("public", %{transport_pid: 2, assigns: %{user: user}})
-
-      assert(
-        %{
-          "public" => [
-            %StreamerSocket{transport_pid: 2},
-            %StreamerSocket{transport_pid: 1}
-          ]
-        } = Streamer.get_sockets()
-      )
-    end
-
-    test "it will not add a duplicate socket", %{user: user} do
-      Streamer.add_socket("activity", %{transport_pid: 1, assigns: %{user: user}})
-      Streamer.add_socket("activity", %{transport_pid: 1, assigns: %{user: user}})
-
-      assert(
-        %{
-          "activity" => [
-            %StreamerSocket{transport_pid: 1}
-          ]
-        } = Streamer.get_sockets()
-      )
-    end
-  end
-end
diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs
index 3c0f240f5..ee530f4e9 100644
--- a/test/web/streamer/streamer_test.exs
+++ b/test/web/streamer/streamer_test.exs
@@ -12,13 +12,9 @@ defmodule Pleroma.Web.StreamerTest do
   alias Pleroma.User
   alias Pleroma.Web.CommonAPI
   alias Pleroma.Web.Streamer
-  alias Pleroma.Web.Streamer.StreamerSocket
-  alias Pleroma.Web.Streamer.Worker
 
   @moduletag needs_streamer: true, capture_log: true
 
-  @streamer_timeout 150
-  @streamer_start_wait 10
   setup do: clear_config([:instance, :skip_thread_containment])
 
   describe "user streams" do
@@ -29,69 +25,35 @@ defmodule Pleroma.Web.StreamerTest do
     end
 
     test "it streams the user's post in the 'user' stream", %{user: user} do
-      task =
-        Task.async(fn ->
-          assert_receive {:text, _}, @streamer_timeout
-        end)
-
-      Streamer.add_socket(
-        "user",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
-
+      Streamer.add_socket("user", user)
       {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"})
-
-      Streamer.stream("user", activity)
-      Task.await(task)
+      assert_receive {:render_with_user, _, _, ^activity}
+      refute Streamer.filtered_by_user?(user, activity)
     end
 
     test "it streams boosts of the user in the 'user' stream", %{user: user} do
-      task =
-        Task.async(fn ->
-          assert_receive {:text, _}, @streamer_timeout
-        end)
-
-      Streamer.add_socket(
-        "user",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
+      Streamer.add_socket("user", user)
 
       other_user = insert(:user)
       {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hey"})
       {:ok, announce, _} = CommonAPI.repeat(activity.id, user)
 
-      Streamer.stream("user", announce)
-      Task.await(task)
+      assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce}
+      refute Streamer.filtered_by_user?(user, announce)
     end
 
     test "it sends notify to in the 'user' stream", %{user: user, notify: notify} do
-      task =
-        Task.async(fn ->
-          assert_receive {:text, _}, @streamer_timeout
-        end)
-
-      Streamer.add_socket(
-        "user",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
-
+      Streamer.add_socket("user", user)
       Streamer.stream("user", notify)
-      Task.await(task)
+      assert_receive {:render_with_user, _, _, ^notify}
+      refute Streamer.filtered_by_user?(user, notify)
     end
 
     test "it sends notify to in the 'user:notification' stream", %{user: user, notify: notify} do
-      task =
-        Task.async(fn ->
-          assert_receive {:text, _}, @streamer_timeout
-        end)
-
-      Streamer.add_socket(
-        "user:notification",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
-
+      Streamer.add_socket("user:notification", user)
       Streamer.stream("user:notification", notify)
-      Task.await(task)
+      assert_receive {:render_with_user, _, _, ^notify}
+      refute Streamer.filtered_by_user?(user, notify)
     end
 
     test "it doesn't send notify to the 'user:notification' stream when a user is blocked", %{
@@ -100,18 +62,12 @@ defmodule Pleroma.Web.StreamerTest do
       blocked = insert(:user)
       {:ok, _user_relationship} = User.block(user, blocked)
 
-      task = Task.async(fn -> refute_receive {:text, _}, @streamer_timeout end)
-
-      Streamer.add_socket(
-        "user:notification",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
+      Streamer.add_socket("user:notification", user)
 
       {:ok, activity} = CommonAPI.post(user, %{"status" => ":("})
-      {:ok, notif} = CommonAPI.favorite(blocked, activity.id)
+      {:ok, _} = CommonAPI.favorite(blocked, activity.id)
 
-      Streamer.stream("user:notification", notif)
-      Task.await(task)
+      refute_receive _
     end
 
     test "it doesn't send notify to the 'user:notification' stream when a thread is muted", %{
@@ -119,45 +75,50 @@ defmodule Pleroma.Web.StreamerTest do
     } do
       user2 = insert(:user)
 
-      task = Task.async(fn -> refute_receive {:text, _}, @streamer_timeout end)
-
-      Streamer.add_socket(
-        "user:notification",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
-
       {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"})
-      {:ok, activity} = CommonAPI.add_mute(user, activity)
-      {:ok, notif} = CommonAPI.favorite(user2, activity.id)
+      {:ok, _} = CommonAPI.add_mute(user, activity)
 
-      Streamer.stream("user:notification", notif)
-      Task.await(task)
+      Streamer.add_socket("user:notification", user)
+
+      {:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id)
+
+      refute_receive _
+      assert Streamer.filtered_by_user?(user, favorite_activity)
     end
 
-    test "it doesn't send notify to the 'user:notification' stream' when a domain is blocked", %{
+    test "it sends favorite to 'user:notification' stream'", %{
       user: user
     } do
       user2 = insert(:user, %{ap_id: "https://hecking-lewd-place.com/user/meanie"})
 
-      task = Task.async(fn -> refute_receive {:text, _}, @streamer_timeout end)
+      {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"})
+      Streamer.add_socket("user:notification", user)
+      {:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id)
 
-      Streamer.add_socket(
-        "user:notification",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
+      assert_receive {:render_with_user, _, "notification.json", notif}
+      assert notif.activity.id == favorite_activity.id
+      refute Streamer.filtered_by_user?(user, notif)
+    end
+
+    test "it doesn't send the 'user:notification' stream' when a domain is blocked", %{
+      user: user
+    } do
+      user2 = insert(:user, %{ap_id: "https://hecking-lewd-place.com/user/meanie"})
 
       {:ok, user} = User.block_domain(user, "hecking-lewd-place.com")
       {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"})
-      {:ok, notif} = CommonAPI.favorite(user2, activity.id)
+      Streamer.add_socket("user:notification", user)
+      {:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id)
 
-      Streamer.stream("user:notification", notif)
-      Task.await(task)
+      refute_receive _
+      assert Streamer.filtered_by_user?(user, favorite_activity)
     end
 
     test "it sends follow activities to the 'user:notification' stream", %{
       user: user
     } do
       user_url = user.ap_id
+      user2 = insert(:user)
 
       body =
         File.read!("test/fixtures/users_mock/localhost.json")
@@ -169,47 +130,24 @@ defmodule Pleroma.Web.StreamerTest do
           %Tesla.Env{status: 200, body: body}
       end)
 
-      user2 = insert(:user)
-      task = Task.async(fn -> assert_receive {:text, _}, @streamer_timeout end)
+      Streamer.add_socket("user:notification", user)
+      {:ok, _follower, _followed, follow_activity} = CommonAPI.follow(user2, user)
 
-      Process.sleep(@streamer_start_wait)
-
-      Streamer.add_socket(
-        "user:notification",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
-
-      {:ok, _follower, _followed, _activity} = CommonAPI.follow(user2, user)
-
-      # We don't directly pipe the notification to the streamer as it's already
-      # generated as a side effect of CommonAPI.follow().
-      Task.await(task)
+      assert_receive {:render_with_user, _, "notification.json", notif}
+      assert notif.activity.id == follow_activity.id
+      refute Streamer.filtered_by_user?(user, notif)
     end
   end
 
-  test "it sends to public" do
+  test "it sends to public authenticated" do
     user = insert(:user)
     other_user = insert(:user)
 
-    task =
-      Task.async(fn ->
-        assert_receive {:text, _}, @streamer_timeout
-      end)
+    Streamer.add_socket("public", other_user)
 
-    fake_socket = %StreamerSocket{
-      transport_pid: task.pid,
-      user: user
-    }
-
-    {:ok, activity} = CommonAPI.post(other_user, %{"status" => "Test"})
-
-    topics = %{
-      "public" => [fake_socket]
-    }
-
-    Worker.push_to_socket(topics, "public", activity)
-
-    Task.await(task)
+    {:ok, activity} = CommonAPI.post(user, %{"status" => "Test"})
+    assert_receive {:render_with_user, _, _, ^activity}
+    refute Streamer.filtered_by_user?(user, activity)
   end
 
   test "works for deletions" do
@@ -217,37 +155,32 @@ defmodule Pleroma.Web.StreamerTest do
     other_user = insert(:user)
     {:ok, activity} = CommonAPI.post(other_user, %{"status" => "Test"})
 
-    task =
-      Task.async(fn ->
-        expected_event =
-          %{
-            "event" => "delete",
-            "payload" => activity.id
-          }
-          |> Jason.encode!()
+    Streamer.add_socket("public", user)
 
-        assert_receive {:text, received_event}, @streamer_timeout
-        assert received_event == expected_event
-      end)
+    {:ok, _} = CommonAPI.delete(activity.id, other_user)
+    activity_id = activity.id
+    assert_receive {:text, event}
+    assert %{"event" => "delete", "payload" => ^activity_id} = Jason.decode!(event)
+  end
 
-    fake_socket = %StreamerSocket{
-      transport_pid: task.pid,
-      user: user
-    }
+  test "it sends to public unauthenticated" do
+    user = insert(:user)
 
-    {:ok, activity} = CommonAPI.delete(activity.id, other_user)
+    Streamer.add_socket("public", nil)
 
-    topics = %{
-      "public" => [fake_socket]
-    }
+    {:ok, activity} = CommonAPI.post(user, %{"status" => "Test"})
+    activity_id = activity.id
+    assert_receive {:text, event}
+    assert %{"event" => "update", "payload" => payload} = Jason.decode!(event)
+    assert %{"id" => ^activity_id} = Jason.decode!(payload)
 
-    Worker.push_to_socket(topics, "public", activity)
-
-    Task.await(task)
+    {:ok, _} = CommonAPI.delete(activity.id, user)
+    assert_receive {:text, event}
+    assert %{"event" => "delete", "payload" => ^activity_id} = Jason.decode!(event)
   end
 
   describe "thread_containment" do
-    test "it doesn't send to user if recipients invalid and thread containment is enabled" do
+    test "it filters to user if recipients invalid and thread containment is enabled" do
       Pleroma.Config.put([:instance, :skip_thread_containment], false)
       author = insert(:user)
       user = insert(:user)
@@ -262,12 +195,10 @@ defmodule Pleroma.Web.StreamerTest do
             )
         )
 
-      task = Task.async(fn -> refute_receive {:text, _}, 1_000 end)
-      fake_socket = %StreamerSocket{transport_pid: task.pid, user: user}
-      topics = %{"public" => [fake_socket]}
-      Worker.push_to_socket(topics, "public", activity)
-
-      Task.await(task)
+      Streamer.add_socket("public", user)
+      Streamer.stream("public", activity)
+      assert_receive {:render_with_user, _, _, ^activity}
+      assert Streamer.filtered_by_user?(user, activity)
     end
 
     test "it sends message if recipients invalid and thread containment is disabled" do
@@ -285,12 +216,11 @@ defmodule Pleroma.Web.StreamerTest do
             )
         )
 
-      task = Task.async(fn -> assert_receive {:text, _}, 1_000 end)
-      fake_socket = %StreamerSocket{transport_pid: task.pid, user: user}
-      topics = %{"public" => [fake_socket]}
-      Worker.push_to_socket(topics, "public", activity)
+      Streamer.add_socket("public", user)
+      Streamer.stream("public", activity)
 
-      Task.await(task)
+      assert_receive {:render_with_user, _, _, ^activity}
+      refute Streamer.filtered_by_user?(user, activity)
     end
 
     test "it sends message if recipients invalid and thread containment is enabled but user's thread containment is disabled" do
@@ -308,255 +238,168 @@ defmodule Pleroma.Web.StreamerTest do
             )
         )
 
-      task = Task.async(fn -> assert_receive {:text, _}, 1_000 end)
-      fake_socket = %StreamerSocket{transport_pid: task.pid, user: user}
-      topics = %{"public" => [fake_socket]}
-      Worker.push_to_socket(topics, "public", activity)
+      Streamer.add_socket("public", user)
+      Streamer.stream("public", activity)
 
-      Task.await(task)
+      assert_receive {:render_with_user, _, _, ^activity}
+      refute Streamer.filtered_by_user?(user, activity)
     end
   end
 
   describe "blocks" do
-    test "it doesn't send messages involving blocked users" do
+    test "it filters messages involving blocked users" do
       user = insert(:user)
       blocked_user = insert(:user)
       {:ok, _user_relationship} = User.block(user, blocked_user)
 
+      Streamer.add_socket("public", user)
       {:ok, activity} = CommonAPI.post(blocked_user, %{"status" => "Test"})
-
-      task =
-        Task.async(fn ->
-          refute_receive {:text, _}, 1_000
-        end)
-
-      fake_socket = %StreamerSocket{
-        transport_pid: task.pid,
-        user: user
-      }
-
-      topics = %{
-        "public" => [fake_socket]
-      }
-
-      Worker.push_to_socket(topics, "public", activity)
-
-      Task.await(task)
+      assert_receive {:render_with_user, _, _, ^activity}
+      assert Streamer.filtered_by_user?(user, activity)
     end
 
-    test "it doesn't send messages transitively involving blocked users" do
+    test "it filters messages transitively involving blocked users" do
       blocker = insert(:user)
       blockee = insert(:user)
       friend = insert(:user)
 
-      task =
-        Task.async(fn ->
-          refute_receive {:text, _}, 1_000
-        end)
-
-      fake_socket = %StreamerSocket{
-        transport_pid: task.pid,
-        user: blocker
-      }
-
-      topics = %{
-        "public" => [fake_socket]
-      }
+      Streamer.add_socket("public", blocker)
 
       {:ok, _user_relationship} = User.block(blocker, blockee)
 
       {:ok, activity_one} = CommonAPI.post(friend, %{"status" => "hey! @#{blockee.nickname}"})
 
-      Worker.push_to_socket(topics, "public", activity_one)
+      assert_receive {:render_with_user, _, _, ^activity_one}
+      assert Streamer.filtered_by_user?(blocker, activity_one)
 
       {:ok, activity_two} = CommonAPI.post(blockee, %{"status" => "hey! @#{friend.nickname}"})
 
-      Worker.push_to_socket(topics, "public", activity_two)
+      assert_receive {:render_with_user, _, _, ^activity_two}
+      assert Streamer.filtered_by_user?(blocker, activity_two)
 
       {:ok, activity_three} = CommonAPI.post(blockee, %{"status" => "hey! @#{blocker.nickname}"})
 
-      Worker.push_to_socket(topics, "public", activity_three)
-
-      Task.await(task)
+      assert_receive {:render_with_user, _, _, ^activity_three}
+      assert Streamer.filtered_by_user?(blocker, activity_three)
     end
   end
 
-  test "it doesn't send unwanted DMs to list" do
-    user_a = insert(:user)
-    user_b = insert(:user)
-    user_c = insert(:user)
+  describe "lists" do
+    test "it doesn't send unwanted DMs to list" do
+      user_a = insert(:user)
+      user_b = insert(:user)
+      user_c = insert(:user)
 
-    {:ok, user_a} = User.follow(user_a, user_b)
+      {:ok, user_a} = User.follow(user_a, user_b)
 
-    {:ok, list} = List.create("Test", user_a)
-    {:ok, list} = List.follow(list, user_b)
+      {:ok, list} = List.create("Test", user_a)
+      {:ok, list} = List.follow(list, user_b)
 
-    {:ok, activity} =
-      CommonAPI.post(user_b, %{
-        "status" => "@#{user_c.nickname} Test",
-        "visibility" => "direct"
-      })
+      Streamer.add_socket("list:#{list.id}", user_a)
 
-    task =
-      Task.async(fn ->
-        refute_receive {:text, _}, 1_000
-      end)
+      {:ok, _activity} =
+        CommonAPI.post(user_b, %{
+          "status" => "@#{user_c.nickname} Test",
+          "visibility" => "direct"
+        })
 
-    fake_socket = %StreamerSocket{
-      transport_pid: task.pid,
-      user: user_a
-    }
+      refute_receive _
+    end
 
-    topics = %{
-      "list:#{list.id}" => [fake_socket]
-    }
+    test "it doesn't send unwanted private posts to list" do
+      user_a = insert(:user)
+      user_b = insert(:user)
 
-    Worker.handle_call({:stream, "list", activity}, self(), topics)
+      {:ok, list} = List.create("Test", user_a)
+      {:ok, list} = List.follow(list, user_b)
 
-    Task.await(task)
+      Streamer.add_socket("list:#{list.id}", user_a)
+
+      {:ok, _activity} =
+        CommonAPI.post(user_b, %{
+          "status" => "Test",
+          "visibility" => "private"
+        })
+
+      refute_receive _
+    end
+
+    test "it sends wanted private posts to list" do
+      user_a = insert(:user)
+      user_b = insert(:user)
+
+      {:ok, user_a} = User.follow(user_a, user_b)
+
+      {:ok, list} = List.create("Test", user_a)
+      {:ok, list} = List.follow(list, user_b)
+
+      Streamer.add_socket("list:#{list.id}", user_a)
+
+      {:ok, activity} =
+        CommonAPI.post(user_b, %{
+          "status" => "Test",
+          "visibility" => "private"
+        })
+
+      assert_receive {:render_with_user, _, _, ^activity}
+      refute Streamer.filtered_by_user?(user_a, activity)
+    end
   end
 
-  test "it doesn't send unwanted private posts to list" do
-    user_a = insert(:user)
-    user_b = insert(:user)
+  describe "muted reblogs" do
+    test "it filters muted reblogs" do
+      user1 = insert(:user)
+      user2 = insert(:user)
+      user3 = insert(:user)
+      CommonAPI.follow(user1, user2)
+      CommonAPI.hide_reblogs(user1, user2)
 
-    {:ok, list} = List.create("Test", user_a)
-    {:ok, list} = List.follow(list, user_b)
+      {:ok, create_activity} = CommonAPI.post(user3, %{"status" => "I'm kawen"})
 
-    {:ok, activity} =
-      CommonAPI.post(user_b, %{
-        "status" => "Test",
-        "visibility" => "private"
-      })
+      Streamer.add_socket("user", user1)
+      {:ok, announce_activity, _} = CommonAPI.repeat(create_activity.id, user2)
+      assert_receive {:render_with_user, _, _, ^announce_activity}
+      assert Streamer.filtered_by_user?(user1, announce_activity)
+    end
 
-    task =
-      Task.async(fn ->
-        refute_receive {:text, _}, 1_000
-      end)
+    test "it filters reblog notification for reblog-muted actors" do
+      user1 = insert(:user)
+      user2 = insert(:user)
+      CommonAPI.follow(user1, user2)
+      CommonAPI.hide_reblogs(user1, user2)
 
-    fake_socket = %StreamerSocket{
-      transport_pid: task.pid,
-      user: user_a
-    }
+      {:ok, create_activity} = CommonAPI.post(user1, %{"status" => "I'm kawen"})
+      Streamer.add_socket("user", user1)
+      {:ok, _favorite_activity, _} = CommonAPI.repeat(create_activity.id, user2)
 
-    topics = %{
-      "list:#{list.id}" => [fake_socket]
-    }
+      assert_receive {:render_with_user, _, "notification.json", notif}
+      assert Streamer.filtered_by_user?(user1, notif)
+    end
 
-    Worker.handle_call({:stream, "list", activity}, self(), topics)
+    test "it send non-reblog notification for reblog-muted actors" do
+      user1 = insert(:user)
+      user2 = insert(:user)
+      CommonAPI.follow(user1, user2)
+      CommonAPI.hide_reblogs(user1, user2)
 
-    Task.await(task)
+      {:ok, create_activity} = CommonAPI.post(user1, %{"status" => "I'm kawen"})
+      Streamer.add_socket("user", user1)
+      {:ok, _favorite_activity} = CommonAPI.favorite(user2, create_activity.id)
+
+      assert_receive {:render_with_user, _, "notification.json", notif}
+      refute Streamer.filtered_by_user?(user1, notif)
+    end
   end
 
-  test "it sends wanted private posts to list" do
-    user_a = insert(:user)
-    user_b = insert(:user)
-
-    {:ok, user_a} = User.follow(user_a, user_b)
-
-    {:ok, list} = List.create("Test", user_a)
-    {:ok, list} = List.follow(list, user_b)
-
-    {:ok, activity} =
-      CommonAPI.post(user_b, %{
-        "status" => "Test",
-        "visibility" => "private"
-      })
-
-    task =
-      Task.async(fn ->
-        assert_receive {:text, _}, 1_000
-      end)
-
-    fake_socket = %StreamerSocket{
-      transport_pid: task.pid,
-      user: user_a
-    }
-
-    Streamer.add_socket(
-      "list:#{list.id}",
-      fake_socket
-    )
-
-    Worker.handle_call({:stream, "list", activity}, self(), %{})
-
-    Task.await(task)
-  end
-
-  test "it doesn't send muted reblogs" do
-    user1 = insert(:user)
-    user2 = insert(:user)
-    user3 = insert(:user)
-    CommonAPI.hide_reblogs(user1, user2)
-
-    {:ok, create_activity} = CommonAPI.post(user3, %{"status" => "I'm kawen"})
-    {:ok, announce_activity, _} = CommonAPI.repeat(create_activity.id, user2)
-
-    task =
-      Task.async(fn ->
-        refute_receive {:text, _}, 1_000
-      end)
-
-    fake_socket = %StreamerSocket{
-      transport_pid: task.pid,
-      user: user1
-    }
-
-    topics = %{
-      "public" => [fake_socket]
-    }
-
-    Worker.push_to_socket(topics, "public", announce_activity)
-
-    Task.await(task)
-  end
-
-  test "it does send non-reblog notification for reblog-muted actors" do
-    user1 = insert(:user)
-    user2 = insert(:user)
-    user3 = insert(:user)
-    CommonAPI.hide_reblogs(user1, user2)
-
-    {:ok, create_activity} = CommonAPI.post(user3, %{"status" => "I'm kawen"})
-    {:ok, favorite_activity} = CommonAPI.favorite(user2, create_activity.id)
-
-    task =
-      Task.async(fn ->
-        assert_receive {:text, _}, 1_000
-      end)
-
-    fake_socket = %StreamerSocket{
-      transport_pid: task.pid,
-      user: user1
-    }
-
-    topics = %{
-      "public" => [fake_socket]
-    }
-
-    Worker.push_to_socket(topics, "public", favorite_activity)
-
-    Task.await(task)
-  end
-
-  test "it doesn't send posts from muted threads" do
+  test "it filters posts from muted threads" do
     user = insert(:user)
     user2 = insert(:user)
+    Streamer.add_socket("user", user2)
     {:ok, user2, user, _activity} = CommonAPI.follow(user2, user)
-
     {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"})
-
-    {:ok, activity} = CommonAPI.add_mute(user2, activity)
-
-    task = Task.async(fn -> refute_receive {:text, _}, @streamer_timeout end)
-
-    Streamer.add_socket(
-      "user",
-      %{transport_pid: task.pid, assigns: %{user: user2}}
-    )
-
-    Streamer.stream("user", activity)
-    Task.await(task)
+    {:ok, _} = CommonAPI.add_mute(user2, activity)
+    assert_receive {:render_with_user, _, _, ^activity}
+    assert Streamer.filtered_by_user?(user2, activity)
   end
 
   describe "direct streams" do
@@ -568,22 +411,7 @@ defmodule Pleroma.Web.StreamerTest do
       user = insert(:user)
       another_user = insert(:user)
 
-      task =
-        Task.async(fn ->
-          assert_receive {:text, received_event}, @streamer_timeout
-
-          assert %{"event" => "conversation", "payload" => received_payload} =
-                   Jason.decode!(received_event)
-
-          assert %{"last_status" => last_status} = Jason.decode!(received_payload)
-          [participation] = Participation.for_user(user)
-          assert last_status["pleroma"]["direct_conversation_id"] == participation.id
-        end)
-
-      Streamer.add_socket(
-        "direct",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
+      Streamer.add_socket("direct", user)
 
       {:ok, _create_activity} =
         CommonAPI.post(another_user, %{
@@ -591,42 +419,47 @@ defmodule Pleroma.Web.StreamerTest do
           "visibility" => "direct"
         })
 
-      Task.await(task)
+      assert_receive {:text, received_event}
+
+      assert %{"event" => "conversation", "payload" => received_payload} =
+               Jason.decode!(received_event)
+
+      assert %{"last_status" => last_status} = Jason.decode!(received_payload)
+      [participation] = Participation.for_user(user)
+      assert last_status["pleroma"]["direct_conversation_id"] == participation.id
     end
 
     test "it doesn't send conversation update to the 'direct' stream when the last message in the conversation is deleted" do
       user = insert(:user)
       another_user = insert(:user)
 
+      Streamer.add_socket("direct", user)
+
       {:ok, create_activity} =
         CommonAPI.post(another_user, %{
           "status" => "hi @#{user.nickname}",
           "visibility" => "direct"
         })
 
-      task =
-        Task.async(fn ->
-          assert_receive {:text, received_event}, @streamer_timeout
-          assert %{"event" => "delete", "payload" => _} = Jason.decode!(received_event)
+      create_activity_id = create_activity.id
+      assert_receive {:render_with_user, _, _, ^create_activity}
+      assert_receive {:text, received_conversation1}
+      assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1)
 
-          refute_receive {:text, _}, @streamer_timeout
-        end)
+      {:ok, _} = CommonAPI.delete(create_activity_id, another_user)
 
-      Process.sleep(@streamer_start_wait)
+      assert_receive {:text, received_event}
 
-      Streamer.add_socket(
-        "direct",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
+      assert %{"event" => "delete", "payload" => ^create_activity_id} =
+               Jason.decode!(received_event)
 
-      {:ok, _} = CommonAPI.delete(create_activity.id, another_user)
-
-      Task.await(task)
+      refute_receive _
     end
 
     test "it sends conversation update to the 'direct' stream when a message is deleted" do
       user = insert(:user)
       another_user = insert(:user)
+      Streamer.add_socket("direct", user)
 
       {:ok, create_activity} =
         CommonAPI.post(another_user, %{
@@ -636,35 +469,30 @@ defmodule Pleroma.Web.StreamerTest do
 
       {:ok, create_activity2} =
         CommonAPI.post(another_user, %{
-          "status" => "hi @#{user.nickname}",
+          "status" => "hi @#{user.nickname} 2",
           "in_reply_to_status_id" => create_activity.id,
           "visibility" => "direct"
         })
 
-      task =
-        Task.async(fn ->
-          assert_receive {:text, received_event}, @streamer_timeout
-          assert %{"event" => "delete", "payload" => _} = Jason.decode!(received_event)
-
-          assert_receive {:text, received_event}, @streamer_timeout
-
-          assert %{"event" => "conversation", "payload" => received_payload} =
-                   Jason.decode!(received_event)
-
-          assert %{"last_status" => last_status} = Jason.decode!(received_payload)
-          assert last_status["id"] == to_string(create_activity.id)
-        end)
-
-      Process.sleep(@streamer_start_wait)
-
-      Streamer.add_socket(
-        "direct",
-        %{transport_pid: task.pid, assigns: %{user: user}}
-      )
+      assert_receive {:render_with_user, _, _, ^create_activity}
+      assert_receive {:render_with_user, _, _, ^create_activity2}
+      assert_receive {:text, received_conversation1}
+      assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1)
+      assert_receive {:text, received_conversation1}
+      assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1)
 
       {:ok, _} = CommonAPI.delete(create_activity2.id, another_user)
 
-      Task.await(task)
+      assert_receive {:text, received_event}
+      assert %{"event" => "delete", "payload" => _} = Jason.decode!(received_event)
+
+      assert_receive {:text, received_event}
+
+      assert %{"event" => "conversation", "payload" => received_payload} =
+               Jason.decode!(received_event)
+
+      assert %{"last_status" => last_status} = Jason.decode!(received_payload)
+      assert last_status["id"] == to_string(create_activity.id)
     end
   end
 end

From 9c3c142c32b027addd7b729229820f8b2bf76994 Mon Sep 17 00:00:00 2001
From: rinpatch <rinpatch@sdf.org>
Date: Thu, 7 May 2020 14:35:29 +0300
Subject: [PATCH 76/76] Restore mix.lock after 2FA merge

It downgraded a bunch of deps, including plug. Which resulted in errors
since pleroma was using a feature plug didn't support at the time.
---
 mix.lock | 32 +++++++++++++++++++++-----------
 1 file changed, 21 insertions(+), 11 deletions(-)

diff --git a/mix.lock b/mix.lock
index 4792249d7..c400202b7 100644
--- a/mix.lock
+++ b/mix.lock
@@ -2,7 +2,8 @@
   "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm", "11b18c220bcc2eab63b5470c038ef10eb6783bcb1fcdb11aa4137defa5ac1bb8"},
   "auto_linker": {:git, "https://git.pleroma.social/pleroma/auto_linker.git", "95e8188490e97505c56636c1379ffdf036c1fdde", [ref: "95e8188490e97505c56636c1379ffdf036c1fdde"]},
   "base62": {:hex, :base62, "1.2.1", "4866763e08555a7b3917064e9eef9194c41667276c51b59de2bc42c6ea65f806", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm", "3b29948de2013d3f93aa898c884a9dff847e7aec75d9d6d8c1dc4c61c2716c42"},
-  "bbcode": {:hex, :bbcode, "0.1.1", "0023e2c7814119b2e620b7add67182e3f6019f92bfec9a22da7e99821aceba70", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5a981b98ac7d366a9b6bf40eac389aaf4d6e623c631e6b6f8a6b571efaafd338"},
+  "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"},
+  "bbcode": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/bbcode.git", "f2d267675e9a7e1ad1ea9beb4cc23382762b66c2", [ref: "v0.2.0"]},
   "bbcode_pleroma": {:hex, :bbcode_pleroma, "0.2.0", "d36f5bca6e2f62261c45be30fa9b92725c0655ad45c99025cb1c3e28e25803ef", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "19851074419a5fedb4ef49e1f01b30df504bb5dbb6d6adfc135238063bebd1c3"},
   "benchee": {:hex, :benchee, "1.0.1", "66b211f9bfd84bd97e6d1beaddf8fc2312aaabe192f776e8931cb0c16f53a521", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm", "3ad58ae787e9c7c94dd7ceda3b587ec2c64604563e049b2a0e8baafae832addb"},
   "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
@@ -18,33 +19,38 @@
   "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "04fd8c6a39edc6aaa9c26123009200fc61f92a3a94f3178c527b70b767c6e605"},
   "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm", "79f954a7021b302186a950a32869dbc185523d99d3e44ce430cd1f3289f41ed4"},
   "credo": {:hex, :credo, "1.1.5", "caec7a3cadd2e58609d7ee25b3931b129e739e070539ad1a0cd7efeeb47014f4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0bbd3222607ccaaac5c0340f7f525c627ae4d7aee6c8c8c108922620c5b6446"},
+  "crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"},
   "crypt": {:git, "https://github.com/msantos/crypt", "1f2b58927ab57e72910191a7ebaeff984382a1d3", [ref: "1f2b58927ab57e72910191a7ebaeff984382a1d3"]},
   "custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm", "8df019facc5ec9603e94f7270f1ac73ddf339f56ade76a721eaa57c1493ba463"},
   "db_connection": {:hex, :db_connection, "2.2.1", "caee17725495f5129cb7faebde001dc4406796f12a62b8949f4ac69315080566", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "2b02ece62d9f983fcd40954e443b7d9e6589664380e5546b2b9b523cd0fb59e1"},
   "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"},
   "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"},
   "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"},
-  "ecto": {:hex, :ecto, "3.4.2", "6890af71025769bd27ef62b1ed1925cfe23f7f0460bcb3041da4b705215ff23e", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3959b8a83e086202a4bd86b4b5e6e71f9f1840813de14a57d502d3fc2ef7132"},
+  "ecto": {:hex, :ecto, "3.4.0", "a7a83ab8359bf816ce729e5e65981ce25b9fc5adfc89c2ea3980f4fed0bfd7c1", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "5eed18252f5b5bbadec56a24112b531343507dbe046273133176b12190ce19cc"},
   "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"},
   "ecto_sql": {:hex, :ecto_sql, "3.3.4", "aa18af12eb875fbcda2f75e608b3bd534ebf020fc4f6448e4672fcdcbb081244", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5eccbdbf92e3c6f213007a82d5dbba4cd9bb659d1a21331f89f408e4c0efd7a8"},
   "esshd": {:hex, :esshd, "0.1.1", "d4dd4c46698093a40a56afecce8a46e246eb35463c457c246dacba2e056f31b5", [:mix], [], "hexpm", "d73e341e3009d390aa36387dc8862860bf9f874c94d9fd92ade2926376f49981"},
   "eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"},
+  "ex2ms": {:hex, :ex2ms, "1.5.0", "19e27f9212be9a96093fed8cdfbef0a2b56c21237196d26760f11dfcfae58e97", [:mix], [], "hexpm"},
   "ex_aws": {:hex, :ex_aws, "2.1.1", "1e4de2106cfbf4e837de41be41cd15813eabc722315e388f0d6bb3732cec47cd", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "1.6.3 or 1.6.5 or 1.7.1 or 1.8.6 or ~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8", [hex: :jsx, repo: "hexpm", optional: true]}, {:poison, ">= 1.2.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "06b6fde12b33bb6d65d5d3493e903ba5a56d57a72350c15285a4298338089e10"},
   "ex_aws_s3": {:hex, :ex_aws_s3, "2.0.2", "c0258bbdfea55de4f98f0b2f0ca61fe402cc696f573815134beb1866e778f47b", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0569f5b211b1a3b12b705fe2a9d0e237eb1360b9d76298028df2346cad13097a"},
   "ex_const": {:hex, :ex_const, "0.2.4", "d06e540c9d834865b012a17407761455efa71d0ce91e5831e86881b9c9d82448", [:mix], [], "hexpm", "96fd346610cc992b8f896ed26a98be82ac4efb065a0578f334a32d60a3ba9767"},
-  "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f1155337ae17ff7a1255217b4c1ceefcd1860b7ceb1a1874031e7a861b052e39"},
+  "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"},
   "ex_machina": {:hex, :ex_machina, "2.3.0", "92a5ad0a8b10ea6314b876a99c8c9e3f25f4dde71a2a835845b136b9adaf199a", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "b84f6af156264530b312a8ab98ac6088f6b77ae5fe2058305c81434aa01fbaf9"},
   "ex_syslogger": {:hex, :ex_syslogger, "1.5.2", "72b6aa2d47a236e999171f2e1ec18698740f40af0bd02c8c650bf5f1fd1bac79", [:mix], [{:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:syslog, "~> 1.1.0", [hex: :syslog, repo: "hexpm", optional: false]}], "hexpm", "ab9fab4136dbc62651ec6f16fa4842f10cf02ab4433fa3d0976c01be99398399"},
   "excoveralls": {:hex, :excoveralls, "0.12.2", "a513defac45c59e310ac42fcf2b8ae96f1f85746410f30b1ff2b710a4b6cd44b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "151c476331d49b45601ffc45f43cb3a8beb396b02a34e3777fea0ad34ae57d89"},
-  "fast_html": {:hex, :fast_html, "1.0.1", "5bc7df4dc4607ec2c314c16414e4111d79a209956c4f5df96602d194c61197f9", [:make, :mix], [], "hexpm", "18e627dd62051a375ef94b197f41e8027c3e8eef0180ab8f81e0543b3dc6900a"},
-  "fast_sanitize": {:hex, :fast_sanitize, "0.1.6", "60a5ae96879956dea409a91a77f5dd2994c24cc10f80eefd8f9892ee4c0c7b25", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b73f50f0cb522dd0331ea8e8c90b408de42c50f37641219d6364f0e3e7efd22c"},
+  "fast_html": {:hex, :fast_html, "1.0.3", "2cc0d4b68496266a1530e0c852cafeaede0bd10cfdee26fda50dc696c203162f", [:make, :mix], [], "hexpm", "ab3d782b639d3c4655fbaec0f9d032c91f8cab8dd791ac7469c2381bc7c32f85"},
+  "fast_sanitize": {:hex, :fast_sanitize, "0.1.7", "2a7cd8734c88a2de6de55022104f8a3b87f1fdbe8bbf131d9049764b53d50d0d", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f39fe8ea08fbac17487c30bf09b7d9f3e12472e51fb07a88ffeb8fd17da8ab67"},
   "flake_id": {:hex, :flake_id, "0.1.0", "7716b086d2e405d09b647121a166498a0d93d1a623bead243e1f74216079ccb3", [:mix], [{:base62, "~> 1.2", [hex: :base62, repo: "hexpm", optional: false]}, {:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "31fc8090fde1acd267c07c36ea7365b8604055f897d3a53dd967658c691bd827"},
-  "floki": {:hex, :floki, "0.26.0", "4df88977e2e357c6720e1b650f613444bfb48c5acfc6a0c646ab007d08ad13bf", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "e7b66ce7feef5518a9cd9fc7b52dd62a64028bd9cb6d6ad282a0f0fc90a4ae52"},
+  "floki": {:hex, :floki, "0.25.0", "b1c9ddf5f32a3a90b43b76f3386ca054325dc2478af020e87b5111c19f2284ac", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "631f4e627c46d5ecd347df5a2accdaf0621c77c3693c5b75a8ad58e84c61f242"},
   "gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"},
+  "gen_stage": {:hex, :gen_stage, "0.14.3", "d0c66f1c87faa301c1a85a809a3ee9097a4264b2edf7644bf5c123237ef732bf", [:mix], [], "hexpm"},
+  "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"},
   "gettext": {:hex, :gettext, "0.17.4", "f13088e1ec10ce01665cf25f5ff779e7df3f2dc71b37084976cf89d1aa124d5c", [:mix], [], "hexpm", "3c75b5ea8288e2ee7ea503ff9e30dfe4d07ad3c054576a6e60040e79a801e14d"},
   "gun": {:git, "https://github.com/ninenines/gun.git", "e1a69b36b180a574c0ac314ced9613fdd52312cc", [ref: "e1a69b36b180a574c0ac314ced9613fdd52312cc"]},
   "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"},
   "html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"},
+  "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.3.0", "f005ad692b717691203f940c686208aa3d8ffd9dd4bb3699240096a51fa9564e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"},
   "http_signatures": {:git, "https://git.pleroma.social/pleroma/http_signatures.git", "293d77bb6f4a67ac8bde1428735c3b42f22cbb30", [ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"]},
   "httpoison": {:hex, :httpoison, "1.6.2", "ace7c8d3a361cebccbed19c283c349b3d26991eff73a1eaaa8abae2e3c8089b6", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "aa2c74bd271af34239a3948779612f87df2422c2fdcfdbcec28d9c105f0773fe"},
   "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"},
@@ -53,34 +59,38 @@
   "joken": {:hex, :joken, "2.2.0", "2daa1b12be05184aff7b5ace1d43ca1f81345962285fff3f88db74927c954d3a", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "b4f92e30388206f869dd25d1af628a1d99d7586e5cf0672f64d4df84c4d2f5e9"},
   "jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm", "3c7ddc8a9394b92891db7c2771da94bf819834a1a4c92e30857b7d582e2f8257"},
   "jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"},
+  "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"},
   "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"},
   "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"},
   "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"},
   "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
   "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"},
   "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
+  "mochiweb": {:hex, :mochiweb, "2.18.0", "eb55f1db3e6e960fac4e6db4e2db9ec3602cc9f30b86cd1481d56545c3145d2e", [:rebar3], [], "hexpm"},
   "mock": {:hex, :mock, "0.3.4", "c5862eb3b8c64237f45f586cf00c9d892ba07bb48305a43319d428ce3c2897dd", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "e6d886252f1a41f4ba06ecf2b4c8d38760b34b1c08a11c28f7397b2e03995964"},
   "mogrify": {:hex, :mogrify, "0.6.1", "de1b527514f2d95a7bbe9642eb556061afb337e220cf97adbf3a4e6438ed70af", [:mix], [], "hexpm", "3bc928d817974fa10cc11e6c89b9a9361e37e96dbbf3d868c41094ec05745dcd"},
   "mox": {:hex, :mox, "0.5.1", "f86bb36026aac1e6f924a4b6d024b05e9adbed5c63e8daa069bd66fb3292165b", [:mix], [], "hexpm", "052346cf322311c49a0f22789f3698eea030eec09b8c47367f0686ef2634ae14"},
+  "myhtmlex": {:git, "https://git.pleroma.social/pleroma/myhtmlex.git", "ad0097e2f61d4953bfef20fb6abddf23b87111e6", [ref: "ad0097e2f61d4953bfef20fb6abddf23b87111e6", submodules: true]},
   "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"},
+  "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]},
   "oban": {:hex, :oban, "1.2.0", "7cca94d341be43d220571e28f69131c4afc21095b25257397f50973d3fc59b07", [:mix], [{:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ba5f8b3f7d76967b3e23cf8014f6a13e4ccb33431e4808f036709a7f822362ee"},
   "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "b862ebd78de0df95875cf46feb6e9607130dc2a8", [ref: "b862ebd78de0df95875cf46feb6e9607130dc2a8"]},
   "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"},
   "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "0.12.4", "8dd29ed783f2e12195d7e0a4640effc0a7c37e6537da491f1db01839eee6d053", [:mix], [], "hexpm", "595d09db74cb093b1903381c9de423276a931a2480a46a1a5dc7f932a2a6375b"},
-  "phoenix": {:hex, :phoenix, "1.4.12", "b86fa85a2ba336f5de068549de5ccceec356fd413264a9637e7733395d6cc4ea", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "58331ade6d77e1312a3d976f0fa41803b8f004b2b5f489193425bc46aea3ed30"},
+  "phoenix": {:hex, :phoenix, "1.4.13", "67271ad69b51f3719354604f4a3f968f83aa61c19199343656c9caee057ff3b8", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ab765a0feddb81fc62e2116c827b5f068df85159c162bee760745276ad7ddc1b"},
   "phoenix_ecto": {:hex, :phoenix_ecto, "4.1.0", "a044d0756d0464c5a541b4a0bf4bcaf89bffcaf92468862408290682c73ae50d", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c5e666a341ff104d0399d8f0e4ff094559b2fde13a5985d4cb5023b2c2ac558b"},
   "phoenix_html": {:hex, :phoenix_html, "2.14.0", "d8c6bc28acc8e65f8ea0080ee05aa13d912c8758699283b8d3427b655aabe284", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b0bb30eda478a06dbfbe96728061a93833db3861a49ccb516f839ecb08493fbb"},
   "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm", "1f13f9f0f3e769a667a6b6828d29dec37497a082d195cc52dbef401a9b69bf38"},
   "phoenix_swoosh": {:hex, :phoenix_swoosh, "0.2.0", "a7e0b32077cd6d2323ae15198839b05d9caddfa20663fd85787479e81f89520e", [:mix], [{:phoenix, "~> 1.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:swoosh, "~> 0.1", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "ebf1bfa7b3c1c850c04929afe02e2e0d7ab135e0706332c865de03e761676b1f"},
-  "plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "164baaeb382d19beee0ec484492aa82a9c8685770aee33b24ec727a0971b34d0"},
-  "plug_cowboy": {:hex, :plug_cowboy, "2.1.1", "a196e4f428d7f5d6dba5ded314cc55cd0fbddf1110af620f75c0190e77844b33", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "15a3c34ffaccef8a0b575b8d39ab1b9044586d7dab917292cdc44cf2737df7f2"},
-  "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm", "73c1682f0e414cfb5d9b95c8e8cd6ffcfdae699e3b05e1db744e58b7be857759"},
+  "plug": {:hex, :plug, "1.9.0", "8d7c4e26962283ff9f8f3347bd73838e2413fbc38b7bb5467d5924f68f3a5a4a", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "9902eda2c52ada2a096434682e99a2493f5d06a94d6ac6bcfff9805f952350f1"},
+  "plug_cowboy": {:hex, :plug_cowboy, "2.1.2", "8b0addb5908c5238fac38e442e81b6fcd32788eaa03246b4d55d147c47c5805e", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "7d722581ce865a237e14da6d946f92704101740a256bd13ec91e63c0b122fc70"},
+  "plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"},
   "plug_static_index_html": {:hex, :plug_static_index_html, "1.0.0", "840123d4d3975585133485ea86af73cb2600afd7f2a976f9f5fd8b3808e636a0", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "79fd4fcf34d110605c26560cbae8f23c603ec4158c08298bd4360fdea90bb5cf"},
   "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"},
   "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
   "postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "4737ce62a31747b4c63c12b20c62307e51bb4fcd730ca0c32c280991e0606c90"},
   "pot": {:hex, :pot, "0.10.2", "9895c83bcff8cd22d9f5bc79dfc88a188176b261b618ad70d93faf5c5ca36e67", [:rebar3], [], "hexpm", "ac589a8e296b7802681e93cd0a436faec117ea63e9916709c628df31e17e91e2"},
-  "prometheus": {:hex, :prometheus, "4.4.1", "1e96073b3ed7788053768fea779cbc896ddc3bdd9ba60687f2ad50b252ac87d6", [:mix, :rebar3], [], "hexpm", "d39f2ce1f3f29f3bf04f915aa3cf9c7cd4d2cee2f975e05f526e06cae9b7c902"},
+  "prometheus": {:hex, :prometheus, "4.5.0", "8f4a2246fe0beb50af0f77c5e0a5bb78fe575c34a9655d7f8bc743aad1c6bf76", [:mix, :rebar3], [], "hexpm", "679b5215480fff612b8351f45c839d995a07ce403e42ff02f1c6b20960d41a4e"},
   "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.3", "3dd4da1812b8e0dbee81ea58bb3b62ed7588f2eae0c9e97e434c46807ff82311", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "8d66289f77f913b37eda81fd287340c17e61a447549deb28efc254532b2bed82"},
   "prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm", "9fd13404a48437e044b288b41f76e64acd9735fb8b0e3809f494811dfa66d0fb"},
   "prometheus_phoenix": {:hex, :prometheus_phoenix, "1.3.0", "c4b527e0b3a9ef1af26bdcfbfad3998f37795b9185d475ca610fe4388fdd3bb5", [:mix], [{:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.3 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "c4d1404ac4e9d3d963da601db2a7d8ea31194f0017057fabf0cfb9bf5a6c8c75"},