From b8056e69e0a2505fc466dd5742b0986b7c1895ae Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 19:08:08 +0200 Subject: [PATCH 01/75] 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 Date: Wed, 29 Apr 2020 19:08:36 +0200 Subject: [PATCH 02/75] 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 Date: Wed, 29 Apr 2020 19:09:51 +0200 Subject: [PATCH 03/75] 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 @@ def validate(%{"type" => "Like"} = object, meta) 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 +# 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 Date: Thu, 30 Apr 2020 13:57:47 +0200 Subject: [PATCH 04/75] 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 @@ def get_latest_reaction(internal_activity_id, %{ap_id: ap_id}, emoji) 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 Date: Thu, 30 Apr 2020 14:13:08 +0200 Subject: [PATCH 05/75] 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 @@ def stringify_keys(object) 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 Date: Thu, 30 Apr 2020 14:14:00 +0200 Subject: [PATCH 06/75] 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 +# 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 @@ test "it works for incoming update activities which lock the account" 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 Date: Thu, 30 Apr 2020 14:37:14 +0200 Subject: [PATCH 07/75] 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 @@ def validate_data(cng) 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 @@ test "it's invalid if the actor of the object and the actor of delete are from d 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 Date: Thu, 30 Apr 2020 15:26:23 +0200 Subject: [PATCH 08/75] 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 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) 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 @@ def handle_incoming( 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 @@ test "it fails for incoming deletes with spoofed origin" 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 @@ test "it fails for incoming user deletes with spoofed origin" 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 Date: Thu, 30 Apr 2020 15:42:30 +0200 Subject: [PATCH 09/75] 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 @@ def validate_object_presence(cng, field_name \\ :object) 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 @@ def validate_data(cng) 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 Date: Thu, 30 Apr 2020 15:57:27 +0200 Subject: [PATCH 10/75] 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 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) 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 @@ test "it handles object deletions", %{delete: delete, post: post, object: object 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 Date: Thu, 30 Apr 2020 15:58:37 +0200 Subject: [PATCH 11/75] 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 Date: Thu, 30 Apr 2020 16:15:38 +0200 Subject: [PATCH 12/75] 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 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) 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 @@ def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, 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 @@ def delete(activity_id, user) 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 Date: Thu, 30 Apr 2020 16:49:41 +0200 Subject: [PATCH 13/75] 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 @@ defp handle_user_activity(%User{} = user, %{"type" => "Create"} = params) 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 Date: Thu, 30 Apr 2020 18:19:51 +0300 Subject: [PATCH 14/75] 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 @@ def perform(%{assigns: %{user: %User{}}} = conn, _) 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 @@ def call(conn, _opts) 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 @@ def feed_redirect(%{assigns: %{format: format}} = conn, _params) 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 @@ def skip_plug(conn) 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 @@ test "it continues if a user is assigned", %{conn: conn} 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 +# 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 Date: Thu, 30 Apr 2020 17:52:29 +0200 Subject: [PATCH 15/75] 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 @@ test "it sends to public" 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 Date: Thu, 30 Apr 2020 17:53:02 +0200 Subject: [PATCH 16/75] 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 @@ def cast_data(data) 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 Date: Thu, 30 Apr 2020 17:58:09 +0200 Subject: [PATCH 17/75] 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 @@ def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, 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 Date: Thu, 30 Apr 2020 17:58:31 +0200 Subject: [PATCH 18/75] 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 Date: Thu, 30 Apr 2020 18:10:36 +0200 Subject: [PATCH 19/75] 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 Date: Thu, 30 Apr 2020 18:19:39 +0200 Subject: [PATCH 20/75] 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 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) 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 @@ def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, %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 @@ test "it handles object deletions", %{delete: delete, post: post, object: object 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 Date: Thu, 30 Apr 2020 18:38:37 +0200 Subject: [PATCH 21/75] 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 Date: Thu, 30 Apr 2020 19:47:13 +0200 Subject: [PATCH 22/75] 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 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) 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 @@ def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, 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 @@ test "it handles object deletions", %{delete: delete, post: post, object: object 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 Date: Thu, 30 Apr 2020 19:53:30 +0200 Subject: [PATCH 23/75] 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 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) 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 @@ test ".delete_user_activities deletes all create activities", %{user: user} 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 @@ test "it deletes a user, all follow relationships and all activities", %{user: u 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 Date: Thu, 30 Apr 2020 20:08:25 +0200 Subject: [PATCH 24/75] 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 @@ def perform(:force_password_reset, user), do: force_password_reset(user) @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 @@ def follow_import(%User{} = follower, followed_identifiers) }) 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 @@ defp delete_activity(%{data: %{"type" => "Like"}} = activity) 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 @@ defp delete_activity(%{data: %{"type" => "Announce"}} = activity) 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 Date: Thu, 30 Apr 2020 20:13:47 +0200 Subject: [PATCH 25/75] 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 @@ defp do_unfollow(follower, followed, activity_id, local) 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 @@ test "creates an undo activity for the last block" 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 Date: Thu, 30 Apr 2020 21:23:18 +0200 Subject: [PATCH 26/75] 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 @@ def validate_data(cng) 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 @@ def validate_same_domain(cng) 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 @@ def common_pipeline(object, meta) 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 @@ test "it's invalid if the object doesn't exist", %{valid_post_delete: valid_post 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 @@ test "it's invalid if the actor of the object and the actor of delete are from d 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 Date: Fri, 1 May 2020 14:31:24 +0300 Subject: [PATCH 27/75] 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 @@ defp compose_query({key, value}, query) 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 @@ defp compose_query({key, values}, query) when key in @contains_criteria and is_l 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 @@ defp compose_query({:limit, limit}, 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 @@ def list_users(conn, params) 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 @@ def user(params \\ %{}) 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 @@ test "renders users array for the first page", %{conn: conn, admin: admin} 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 @@ test "errors", %{conn: conn} 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 @@ test "success", %{conn: conn} 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 Date: Fri, 1 May 2020 13:34:47 +0200 Subject: [PATCH 28/75] 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 @@ def validate_recipients_presence(cng, fields \\ [:to, :cc]) 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 @@ def validate_actor_presence(cng, field_name \\ :actor) 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 @@ def add_deleted_activity_id(cng) 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 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) 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 @@ test "it is valid for a post deletion", %{valid_post_delete: valid_post_delete} 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 Date: Fri, 1 May 2020 14:05:25 +0200 Subject: [PATCH 29/75] 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 @@ def run(["new", nickname, email | rest]) 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 @@ test "user is not created" 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 Date: Fri, 1 May 2020 14:22:39 +0200 Subject: [PATCH 30/75] 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 @@ test "single user", %{admin: admin, conn: conn} 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 Date: Fri, 1 May 2020 14:30:39 +0200 Subject: [PATCH 31/75] 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 @@ def user_delete(conn, %{"nickname" => nickname}) 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 @@ test "GET /api/pleroma/admin/users/:nickname requires " <> 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 Date: Fri, 1 May 2020 16:31:05 +0300 Subject: [PATCH 32/75] 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 Date: Sun, 3 May 2020 12:19:01 +0200 Subject: [PATCH 33/75] 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 @@ def do_not_federate?(cng) 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 Date: Sun, 3 May 2020 12:51:28 +0200 Subject: [PATCH 34/75] 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 @@ def handle_incoming( %{"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 @@ test "it works for incoming deletes" 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 @@ test "it works for incoming deletes" 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 Date: Sun, 3 May 2020 13:01:19 +0200 Subject: [PATCH 35/75] 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 @@ 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) + 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 @@ test "it works for incoming user deletes" 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 @@ test "single user", %{admin: admin, conn: conn} 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 Date: Sun, 3 May 2020 13:02:57 +0200 Subject: [PATCH 36/75] 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 Date: Sun, 3 May 2020 13:48:01 +0200 Subject: [PATCH 37/75] 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 @@ def finger(account) 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 @@ def get( 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 @@ def get( 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 @@ def get( 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 @@ def get("http://framatube.org/.well-known/host-meta", _, _, _) 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 @@ def get("http://gerzilla.de/.well-known/host-meta", _, _, _) 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 @@ def get("http://404.site" <> _, _, _, _) 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 @@ def get( 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 Date: Mon, 4 May 2020 12:22:31 +0200 Subject: [PATCH 38/75] 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 Date: Mon, 4 May 2020 20:16:18 +0400 Subject: [PATCH 39/75] 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 +# 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 Date: Mon, 4 May 2020 16:38:23 +0000 Subject: [PATCH 40/75] 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 +# Copyright © 2019-2020 Moxley Stratton, Mike Buhot , MPL-2.0 +# Copyright © 2020 Pleroma Authors # 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 Date: Mon, 4 May 2020 21:46:25 +0400 Subject: [PATCH 41/75] 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 @@ defp cast_and_validate(spec, operation, conn, content_type) 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 @@ defp cast_and_validate(spec, operation, conn, content_type) 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 Date: Mon, 4 May 2020 22:33:05 +0400 Subject: [PATCH 42/75] 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 @@ 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 @@ def call(%{private: %{open_api_spex: private_data}} = conn, %{ 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 @@ def call( 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 @@ defp list_items_to_string(list) 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 Date: Mon, 4 May 2020 22:33:34 +0400 Subject: [PATCH 43/75] 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 @@ def init(opts), do: opts 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 Date: Mon, 4 May 2020 22:35:28 +0400 Subject: [PATCH 44/75] 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 @@ 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.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" Date: Mon, 4 May 2020 23:32:53 +0200 Subject: [PATCH 45/75] 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 @@ def set_conversation(object) 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 @@ test "it inserts an incoming sensitive activity into the database", %{ 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 Date: Tue, 5 May 2020 09:25:09 +0200 Subject: [PATCH 46/75] 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 @@ def remote_user_changeset(struct \\ %User{local: false}, params) 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 @@ def represent_user(user, "XML") 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 @@ defp webfinger_from_json(doc) 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 @@ test "it work for AP-only user" 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 Date: Tue, 5 May 2020 09:36:38 +0200 Subject: [PATCH 47/75] 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 @@ def represent_user(user, "XML") 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 @@ defp webfinger_from_xml(doc) do ) do data = %{ "subject" => subject, + "subscribe_address" => subscribe_address, "ap_id" => ap_id } From 6a2905ccf08f89bd988b1bcd0788566930fbf17e Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 May 2020 09:55:33 +0200 Subject: [PATCH 48/75] 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 @@ test "it work for AP-only user" 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 Date: Tue, 5 May 2020 10:38:59 +0200 Subject: [PATCH 49/75] 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 Date: Tue, 5 May 2020 14:44:29 +0400 Subject: [PATCH 50/75] 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 @@ ] } ] + }, + %{ + 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 Date: Tue, 5 May 2020 11:17:44 +0000 Subject: [PATCH 51/75] 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 @@ def represent_user(user, "XML") 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 Date: Tue, 5 May 2020 16:43:00 +0400 Subject: [PATCH 52/75] 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 @@ def spec 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 +# 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 +# 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 @@ def create(%{assigns: %{user: user, token: token}} = conn, params) 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 @@ def get(%{assigns: %{user: user, token: token}} = conn, _params) 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 @@ defp restrict_push_enabled(conn, _) 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 @@ def create( %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 @@ defmacro assert_error_when_disable_push(do: yield) 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 @@ test "successful creation", %{conn: conn} 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 @@ test "returns error when push disabled ", %{conn: conn} 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 @@ test "returns error when user hasn't subscription", %{conn: conn} 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 @@ test "returns a user subsciption", %{conn: conn, user: user, token: token} 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 @@ test "returns error when push disabled ", %{conn: conn} 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 @@ test "returns updated subsciption", %{conn: conn, subscription: subscription} 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 @@ test "returns error when push disabled ", %{conn: conn} 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 @@ test "returns error when user hasn't subscription", %{conn: conn} 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 @@ test "returns empty result and delete user subsciption", %{ 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 Date: Tue, 14 Apr 2020 22:50:29 +0400 Subject: [PATCH 53/75] 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 +# 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 +# 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 +# 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 +# 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 @@ test "gets markers with correct scopes", %{conn: conn} 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 @@ test "gets markers with correct scopes", %{conn: conn} do "version" => 0 } } + + assert_schema(response, "MarkersResponse", ApiSpec.spec()) end test "gets markers with missed scopes", %{conn: conn} do @@ -60,6 +64,7 @@ test "creates a marker with correct scopes", %{conn: conn} 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 @@ test "creates a marker with correct scopes", %{conn: conn} do "version" => 0 } } = response + + assert_schema(response, "MarkersResponse", ApiSpec.spec()) end test "updates exist marker", %{conn: conn} do @@ -89,6 +96,7 @@ test "updates exist marker", %{conn: conn} 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 @@ test "updates exist marker", %{conn: conn} 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 Date: Tue, 5 May 2020 16:45:34 +0400 Subject: [PATCH 54/75] 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 @@ def open_api_operation(action) 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 @@ def index_operation 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 -# 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 -# 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 -# 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 @@ def index(%{assigns: %{user: user}} = conn, params) 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 @@ test "gets markers with correct scopes", %{conn: conn} 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 @@ test "gets markers with correct scopes", %{conn: conn} do "version" => 0 } } - - assert_schema(response, "MarkersResponse", ApiSpec.spec()) end test "gets markers with missed scopes", %{conn: conn} do @@ -49,7 +45,7 @@ test "gets markers with missed scopes", %{conn: conn} 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 @@ test "creates a marker with correct scopes", %{conn: conn} 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 @@ test "creates a marker with correct scopes", %{conn: conn} do "version" => 0 } } = response - - assert_schema(response, "MarkersResponse", ApiSpec.spec()) end test "updates exist marker", %{conn: conn} do @@ -101,7 +95,7 @@ test "updates exist marker", %{conn: conn} 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 @@ test "updates exist marker", %{conn: conn} do "version" => 0 } } - - assert_schema(response, "MarkersResponse", ApiSpec.spec()) end test "creates a marker with missed scopes", %{conn: conn} do @@ -122,11 +114,12 @@ test "creates a marker with missed scopes", %{conn: conn} 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 Date: Tue, 5 May 2020 17:05:34 +0400 Subject: [PATCH 55/75] 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 @@ defp mute_request 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 +# 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 +# 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 @@ def index(%{assigns: %{user: user}} = conn, opts) 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 @@ def show(%{assigns: %{list: list}} = conn, _) 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 @@ def list_accounts(%{assigns: %{user: user, list: list}} = conn, _) 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 @@ def add_to_list(%{assigns: %{list: list}} = conn, %{"account_ids" => account_ids 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 @@ def remove_from_list(%{assigns: %{list: list}} = conn, %{"account_ids" => accoun 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 @@ defp json_response_and_validate_schema( 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 @@ test "adding users to a list" 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 @@ test "removing users from a list" 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 @@ test "listing users in a list" 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 @@ test "retrieving a list" 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 @@ test "renders 404 if list is not found" 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 @@ test "validates title when renaming a list" 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 @@ test "deleting a list" 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 Date: Fri, 1 May 2020 19:53:00 +0400 Subject: [PATCH 56/75] 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 @@ def index_operation 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 @@ 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.", + 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 Date: Tue, 5 May 2020 16:08:44 +0300 Subject: [PATCH 57/75] 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. ### 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 @@ def list_statuses(%{assigns: %{user: _admin}} = conn, params) 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 @@ test "returns 403 when requested by anonymous" 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 Date: Tue, 5 May 2020 17:24:16 +0400 Subject: [PATCH 58/75] 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 @@ def calculate_stat_data 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 +# 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 @@ test "get instance stats", %{conn: conn} 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 @@ test "get peers", %{conn: conn} 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 Date: Thu, 30 Apr 2020 17:45:48 +0000 Subject: [PATCH 59/75] 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 @@ def show_operation 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 Date: Fri, 1 May 2020 12:46:53 +0000 Subject: [PATCH 60/75] 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 @@ defp instance 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 Date: Fri, 1 May 2020 12:46:56 +0000 Subject: [PATCH 61/75] 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 @@ defp instance 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 Date: Tue, 5 May 2020 17:40:00 +0400 Subject: [PATCH 62/75] 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 +# 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 @@ def reject(%{assigns: %{user: followed, follower: follower}} = conn, _params) 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 @@ test "/api/v1/follow_requests works", %{user: user, conn: conn} 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 @@ test "/api/v1/follow_requests/:id/authorize works", %{user: user, conn: conn} 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 @@ test "/api/v1/follow_requests/:id/reject works", %{user: user, conn: conn} 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 Date: Tue, 14 Apr 2020 18:36:32 +0400 Subject: [PATCH 63/75] 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 @@ def delete(%Pleroma.Filter{id: filter_key} = filter) when is_nil(filter_key) 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 +# 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 +# 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 +# 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 +# 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 +# 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 @@ test "updating a filter" 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 @@ test "creating a filter" 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 @@ test "creating a filter" 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 @@ test "fetching a list of filters" 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 @@ test "get a filter" 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 @@ test "update a filter" 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 @@ test "update a filter" 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 @@ test "update a filter" 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 @@ test "delete a filter" 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 Date: Mon, 27 Apr 2020 23:54:11 +0400 Subject: [PATCH 64/75] 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 @@ def index_operation 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 @@ def create_operation 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 @@ def show_operation 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 @@ def update_operation 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 @@ def delete_operation 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 -# 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 -# 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 -# 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 -# 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 @@ def create(%{assigns: %{user: user}, body_params: params} = conn, _) 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 @@ def update( ) 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 @@ test "creating a filter" 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 @@ test "fetching a list of filters" do response = conn |> get("/api/v1/filters") - |> json_response(200) + |> json_response_and_validate_schema(200) assert response == render_json( @@ -67,8 +59,6 @@ test "fetching a list of filters" do "index.json", filters: [filter_two, filter_one] ) - - assert_schema(response, "FiltersResponse", ApiSpec.spec()) end test "get a filter" do @@ -85,8 +75,7 @@ test "get a filter" 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 @@ test "update a filter" 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 @@ test "delete a filter" 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 Date: Tue, 28 Apr 2020 16:25:13 +0400 Subject: [PATCH 65/75] 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 Date: Tue, 5 May 2020 17:44:46 +0400 Subject: [PATCH 66/75] 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 @@ def for_user(user, params \\ %{}) 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 @@ def for_user_with_last_activity_id(user, params \\ %{}) 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 +# 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 +# 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 @@ def index(%{assigns: %{user: user}} = conn, params) 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 @@ test "returns a list of conversations", %{user: user_one, conn: conn} 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 @@ test "filters conversations by recipients", %{user: user_one, conn: conn} 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 @@ test "updates the last_status on reply", %{user: user_one, conn: conn} 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 @@ test "the user marks a conversation as read", %{user: user_one, conn: conn} 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 @@ test "the user marks a conversation as read", %{user: user_one, conn: conn} 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 Date: Tue, 5 May 2020 20:14:22 +0400 Subject: [PATCH 67/75] 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 +# 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 @@ def show(%{assigns: %{user: user}} = conn, %{"id" => id}) 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 @@ test "returns poll entity for object id", %{user: user, conn: conn} 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 @@ test "does not expose polls for private statuses", %{conn: conn} 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 @@ test "votes are added to the poll", %{conn: conn} 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 @@ test "author can't vote", %{user: user, conn: conn} 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 @@ test "does not allow multiple choices on a single-choice question", %{conn: conn 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 @@ test "does not allow choice index to be greater than options count", %{conn: con 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 @@ test "returns 404 when poll is private and not available for user", %{conn: conn 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 Date: Tue, 5 May 2020 23:19:16 +0400 Subject: [PATCH 68/75] 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 +# 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 +# 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 Date: Tue, 5 May 2020 23:42:18 +0400 Subject: [PATCH 69/75] 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 +# 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 @@ def show(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, _params) 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 @@ def delete(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, _params 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 @@ defmacro __using__(_opts) 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 @@ test "shows scheduled activities" 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 @@ test "shows a scheduled activity" 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 @@ test "updates a scheduled activity" 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 @@ test "deletes a scheduled activity" 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 @@ test "deletes a scheduled activity" 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 @@ test "attachments" 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 Date: Wed, 6 May 2020 14:18:19 +0400 Subject: [PATCH 70/75] 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 @@ def show_operation 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 Date: Wed, 6 May 2020 16:20:47 +0300 Subject: [PATCH 71/75] 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 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 Date: Wed, 6 May 2020 08:55:09 -0500 Subject: [PATCH 72/75] 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 Date: Wed, 6 May 2020 18:04:16 +0300 Subject: [PATCH 73/75] 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 Date: Thu, 7 May 2020 08:14:54 +0000 Subject: [PATCH 74/75] 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 @@ 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 @@ 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 @@ 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 +# 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 +# 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 +# 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 +# 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 +# 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 +# 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 @@ def init(options) 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 @@ def get_cached_by_nickname_or_id(nickname_or_id, opts \\ []) 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 @@ def force_password_reset(%{assigns: %{user: admin}} = conn, %{"nicknames" => nic 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 @@ def get_user(%Plug.Conn{} = conn) 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 +# 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 @@ defp shortname(name) 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 +# 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 +# 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 @@ def create_authorization( %{"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 @@ defp handle_create_authorization_error( |> 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 @@ def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "authorization_code"} 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 @@ def token_exchange( {: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 @@ def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "client_credentials"} {: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 @@ def registration_details(%Plug.Conn{} = conn, %{"authorization" => auth_attrs}) 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 @@ defp do_create_authorization( %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 @@ defp get_session_registration_id(%Plug.Conn{} = conn), do: get_session(conn, :re 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 +# 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 @@ def build_for_client_credentials(token) 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 +# 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 %> + +<% end %> +<%= if get_flash(@conn, :error) do %> + +<% end %> + +

Two-factor recovery

+ +<%= form_for @conn, mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %> +
+ <%= 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" %> +
+ +<%= submit "Verify" %> +<% end %> +"> + Enter a two-factor code + 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 %> + +<% end %> +<%= if get_flash(@conn, :error) do %> + +<% end %> + +

Two-factor authentication

+ +<%= form_for @conn, mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %> +
+ <%= 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" %> +
+ +<%= submit "Verify" %> +<% end %> +"> + Enter a two-factor recovery code + 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 %> +

<%= @error %>

+<% end %> +

Two-factor authentication

+

<%= @followee.nickname %>

+ +<%= form_for @conn, remote_follow_path(@conn, :do_follow), [as: "mfa"], fn f -> %> +<%= text_input f, :code, placeholder: "Authentication code", required: true %> +
+<%= 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 @@ defp is_status?(acct) 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 @@ def do_follow(%{assigns: %{user: %User{} = user}} = conn, %{"user" => %{"id" => 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 @@ def do_follow(%{assigns: %{user: nil}} = conn, _) 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 @@ defp deps 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=h4k3DwAvi$RS|K+0p zq|S#a)oE;N=LIoGXa4i@|K$Y#L=L?Lk!4});_=Vx{g-2afIyi18w6rwZ~R}~Ul0&5 zw*TZCR$*Xk=<#nZQ}}=$p z3j%@wci;?(=aFMWSW#Pr zM-sk`m0uq8xYb`Cmsw(7J!k}vp6qi1VS~jP7&6A5mE-EG{5)pI7l~c<3JjAJf7Ao{ z%?06O$C!E2hN3FRmRCu5Ow%tiyBh2ns`-x@zc75e`(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; zqHm61jJ91ll 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(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!VcT#(v7JXLXJ@ z){p&>^Um%ZZtNVhmcz^GoNgQ*JD2Oo>FjHq9mA8;&g~p)TpP=ld(Z70YCIUzmdnoT zoNBxpTbJVjbPhDWjPb~=13D*MAQRlPuR!kz7fc1WtlLpW-(^?8JbO;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`%~*cP6rd8;|6V2x@2uxge@=KyBC$ zi|`bR5K!rXMgz%-+SGYrU?UxdcEKn0MB{=ow_;1K8@Ik?DUSk+>#z`~?)SGAcL>qe+6j#(>P6M{$6J#k$uU#{R z-s{lpIh;j{n>TN#5M7|;A&LN1S5Gb5Z@ugDY*&{Z*Za%xtn`OuARbR}-%5Hcw^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 zC08EgJC5_Dkaga%63kZv-zn>M`Ou=?caQ$DJnbbILNgE&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>!F28Wfa^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+D6J5nX4ZS9IUFSSygS3m zHi(kIix6ZDUj9hVGyQyCXIE;>-N_~qDhN_`+%O|_XfCP^MHPtppE@bzt*L~ z{_Q(lz)u=OkiCOy1~;HcrO%BO2{})w&mH<{FMDjjK%GjrLXRo;UFUpHT z`_V1WfO(#mz+cgGdoqeSvoK#0&rT;eTjHl%skfy~wD8zaL3i^tU?zw6p>+kDOji7x z8hy0SzG~Id#U6)C%6=={;CZP9d0tsTFF1M%@Il};%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!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 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 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_iwQl46)CwSP%q-18b$FVh8t_zoQ>{liC%y|> z>3YN1WMK@~ch4(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-TG)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~Yzh^L<@QvQuEWJOAZoiMs`StnunB{Qk$O6s5<(>5x|!PFXz_vK4s&@n&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&{eXoPbPQDjVXp&vBq$nSso=nfEl8C1@v${QKYX1*X|(bh!x@idwn@x_4O>f) zyFU7drfQZr4hD^3R$+%arp8raXeOgpI=voJb&KZAxu;Jg!LZb(}BF>+H3<)2NQaWa-&3RTIggc1U@!%Ld+ zN!mDIq?0KE62X58Wedq1S{A7OXhxlvh6YKL1>vWu^)jImVH5KNqYMQvB`HEfiqMG2 z2I0mMT!M6(GBQM%j+BLXP5;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 zLY&kJ~Q`-)%5q%!j%M*jH4ibKgzNI)6}I-USwsL=m_Eo*+Ruvw%*f zADgLC9jdAOVZ+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 z2cd0DA+W8;GxCRIr_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 zpdnrwji%lJ_&iTVt9!R1 z;c?NTNdQ}{bGn0&5_uacNCQStRu+W5fTj*HSfEV{N5Nj{sk$~Tb(4$s)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>%;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(HQ9g9b!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%+qbjr>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>3uiuOOh0AJ^)C&XNNX_DPPxAvntOwn21~;W^r?9P!qt%qz3%zAv>BA+NgAOpuh81?gt}nnhV;V-* z%Kr`Hg>xFQ)PVm{%xo#>iWGq5T++~H!jNDKYLg<{iI4x@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|mP{zsx7hLOY zo+5^^pg7n76HkJ}9*QyYQH`6RVfLCV;SRnm8?(-1{N@L);9S><#dNsrjcOj3j%wn$ z@%KPe$3YasyWj{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)cSRZf7QT*Dqokvu$+nVLXa<1JmSfM zac@$*tg%_oo5ajpFfH)efc63PGBmtHz(M~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!iCroC#`O>F6NLa@HFSO7H2>f;VO7(LyX(^Y zZ63iW{YtGlHBbQKXPBRZaU-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;P54ZhcJn0y!{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-uJHw$c7W%H_RD?_W8)4G|vE*5taVOVm=uhqeo)A%8#oUERPxuJ+?W%65frzV2MP=KhY}=p9nNV_UU+ z&ZmX+e;6jKClkj4JmD0GW6<%D$z+f}2 zInWYK^V4T*->xFQzBbac^#zXEXDBanCszCP^5 z9{Z5Q+1WV>Jz6Bz20;$3V#PhHwc01)r`g02z!i%c8!pIgwX<9QbOBkY#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;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;GLn1r=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?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_tIGH*$&G(jK5jV-fafM0+Z&$Czpc&FZzyd4gk68!lrq{D- zDbK(?VbErfa*@lyjZA6%&Y>qeRFpn0(Y$%abiK95(`t4p*Eols$7jTCO>OC>&)x2U zhJX|!uibcD`9}6CbA@u+q}{T)P=(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_*3OLu%EdhLg4>1SaC&$>Rhz5x~j-ITEH)7u}#)cl_JTLUSCTX2JqE~&`qSrE={qBo$3 zV=!d>n*Bgp9V697`&EbmvN`lArWKlQu*wRfQ`V0Bo`}_RcXW;w&9!h1_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!aLuMWvfHi*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*pye)3iN!JfiV zLF$les_t$V?o4_0W>2OO&N=C+XoRVDDIa~DEUF_k!YRmWMN`v&o?b!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!b7PZ1c+=%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+FHRDR;`rK4>3)x-kd?Bmx1tYVoRK|sT+ID;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&#=@}P7g{&$s)f?<(~nLM(BVfSMGUpl=J|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<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 z%uH>0IwN0oxQq2>f{PVKR?DZpYJlppYOC9V8H-T=>benjT7ij))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`(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?PgRTjpObkrCr$WDX-y4qN$@M(tnxTBi6GI5KN0=ogfHP)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|XeZq-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+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#bwcmsUtVil?LpB2*_ve1s0a+!HPECKTm3r{ZP7jRd+*NM$`M&5F%V=o1Z z5DRX_-5!$%^E*1plQm%u3kTPDL_$#rAU{1XNAP`8ouLQE;~q)FI!0KRi2pfKH=Zp2 zij+IJ+Ge)ZE-%w zaw^d!Mp`o$^xPEw{gECpxFbJ&wAa|XQ-S*Pnz{%5%{Z`@&~%kLhciAqD@F_HWZKZ$ z*2IXYT$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~>Nouy=4-=7HNpn zCA+<<*7PNFi8KY9wRfUFx$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@`uNy1U(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+cdk{$ZQ3VDGVSNp(B$u|@=1DBNwa>P$Kc|MW(k)lyoiUQ6iuDxGqhU(?6o+Nh^g zE@+&iN4UQ<5sIX!ekr@Fzx)v?!f^S~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^;9okaNJ!!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)XLW6oC0%R%t7gUMVGboW6B0ii8;+aP91rfR>Ld`4OqHeQk`NX)QglRgR070 z)$P@VlJF;Mvv|^&L${1i+<4@obI$4T%tQ#|A{tBmHRRUlHpe-$L?cznSE1yzb=Za)|t# zSbss{Mx*=_;OG9EO)8hOygv8p@_Jz&4WWC|DlIFCh}h&PCB>A;eXL*9Q9w6C$zhcRXMMes3RZ_K@Flx)p)AW( z`o5LHg=4HzCBBpG=PU%2upEnV~;h{w?l_Q0RP;yH<>2BiyV zjrVfuDI$FU)E^$XbSGawC&OUINLdpT^uU%a2Pi%8f`)f9m1&ewqjzLBK;nwIVpCB| zt%A>2^Md51{AA>jLfc*SAGD^xf081YI8aC_mJNN}+Gnkz{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^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@+*rr95TdKJ{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`5AIwQS8B50_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*Aag~w{+oDQ?1hmyjeE2cV|j;nz^ggjLct&4ySTv2ggKcLJs7#w zC?*MR-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;&tK9Yx{Aq& zF_u;=r!8cXJ*gHyg%nN{UJc7{hJQVtGKV~*+Z{5e#>)yP^0?NPtl7kjG7(ymHr9DO zbsMz^2Bvt$PCQ4mg_%(HZJ&aMzj!WS2A8EW`X@<^DEUFB{1ULuevM#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)MuRh_iP``lI|WvH1OstT$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%!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<-GPQ3V0QHv+-CE%*H1R6}LXJgU-XQD{E)fp$Ha=zdLSF>P zPlXq+_Fqz>d1^SffaW~+3GYK+BF84PJ-`63@POHsUUgucjI7uzInsCNc8M{PA~tue)00ODY1BIh@>2evGp0}(oHn>Y-8~JHh*gE#_542 zJ?BS*zFB9@i&>kV?OM|wTy@Tnu7ZPzx`(ph(byZ~HO{qLQib9}B(6+KRqRz0KyfFuGF;>IM)+%ok76@p@TW#G!wl5% zcrE?CP!F7ZZp!d(r0GAV4&c^w#njQ+%5opdMNXy_VSC24ZpB_8%IHivt+3@w%!)wS_VfM%4+1A72 zC2N&8FR9`QoxokU3&P|X6lbenKw^kujAl`ToAN5d4ioCcJWHeHVbS_WOUUwhbJK%m z1XKz5;&A`RJd5RF61CZ$u@Vw50x@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;*O5rsX4U-|ah>-*fG}(gfMD?y}ENNs?%I z#jQQ{!wI(|KF2@w^{qSa3bXvKt?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$yE7^2pw~M(z(0PQHz`Ma1D?JTp6LU2ljS_NE%1S$GB3gj`hbD1!;47=6Bc%J zJvKLeV>KRMy)iu(Ykt^~B@SP$$5(&md*Fx8T%cc4pd}XtZ9`atG;Jv1!n8H|!r{8W%Z6f{PX-x4d!HMmfj=eJs|CF8G0m2|9FI!%-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@gRYr0;4_#|cUF!JW`Dj^01U6p*0ss>x~vyYMFT2q_-0G~qu1wflB;BRMZ7yp;;-;X^^r5>tTaGsa#5ab@M1W?MqmX@Af zj_-*tM~Ifz$zeUM5f6vy;=2oUb&G53h~5Z}XqC%;&GNzbbt?tbtf@@mC=Q>=H*kTE}Lf;D7!kJ7(jUxlIIrHlNTtxa8g}72L-7& ze*R8{9W3uLfocV)oM2D#>5RPr3~Dl>SY`~J%{TXLAPxHmB@~HAnWdXPj=I^PzpUQ&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}FjajT}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!Tv7Aupj&&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-<^nvc1-N6DcL<;RKw36VTFd z3Oar}g9NcPGvE9>GalOjuJ8#Dr|X7xVh>$rCvK4Mgq!4h;TE|s+(r*0;STjDVIPl} z3ioi0oD;m(1+zY0ggsp1Rk%TZ6K;~d5Jp zS{~IlHhE%l=j&8wI(G}b-lvh3OhTw_xiz^O1w&EhI@k7hMtN9|ol8_=O{Qk1YDgZ&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(&@uOX5JcThrk#UOd)Z1 z%ut|21%(<%p|dwfd!7?9=Ip&g?r;ZxG(O9`{Mz4ARdGV;~{t`9)^eG5qKmX zg-7Etcq|@=$KwfjBA$dN<0*J5o`$F68F(h1g=gbAcrKoY=i>!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*Q9gVRjg3~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&$z{T1h&=>89xt9jKsPCRYtrrw;1McB~w zaZ?qF&qDXuw5smVe<|xIrz`SoIAVMjkCe5l?6D1*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@sa70zQl$B%I z2BS;%I|r$tcWt99XJU4+me$HhC+7&una(K$#;}Rl=2K=fcf}GXhJGPeE8N&x^B(AW zo;_aFpY?lP&wDbaDxwlkSGI(z78QX^RSE9w2%r}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!+ zZcF?5VJLspUofc|(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?37uwAEEu}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=;`-)2XP$k|f1GWuX6%Ot7;`xouBhpwX?C)SUdphg(iHviN^u5fPaC+oZByyg zzcGir_f7q&&(e)DJ$j1?Z^B^-Q|K)I@C=^5hPlNNoweg1V@JiY{F0s3u~x9n;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#@;>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&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`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@`*TTjRRhE5A5+p3k{!$WT zEzAB&5~ME6zAXvzmu26P1WC-Yzm^1<%(BChAe~wEHdy=3PSoV*S zpdVQFeM!(1Ec<~Z=nR(qlO$*lmi@CN=n5qG}CJ&L0>byRuVKf)9WNbhcmri60|wfLz1A^ncg4?8lLG9NznC7 zZCMG|lV(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=%$C$oc5|A0wS4aXzWBN)-Kx<52B?-8V>8m9H z!7+V}Bw#tFuayK;$MlmV0pBrwog^SVrmvR-%*XVTB?0|0eS;+6K&Ee$1VqU6O_G2O znVymal*shWl7JVPPJI;w0l1Rp;VrBXsNx-g5KT8tJAl)koc$VpB zO9HZG`Z5F4NSjT z5-bR&e@PN-38vp7304KuZ!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}O7noJc#raNn{_1zuoK!9_Q~Uvy}(bI(4`Dchfr4`{o_y+bmNpS6be; zwpw>u?Tet0?&9EP|zvGzU_>$A)+~@qrrMW)qdfDxCuXBG?y0rB1GH2P= zvLl|go)^7#?`rRJzE0nry}<+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{bPf13b|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^ticoBKM5p7FK4(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}Abdry9?Zoy<;{aT`X^OoPQ zS?QfU|JR2r{V%xe5f{%?PjFQCOtjVny`5pa18=I^c!5>jx&GukTAJDywb*8!KX1_) zR`WXJiPx(6xp6NFCqsob;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`dVDVEVHjf1o-=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@dHgtrqr2s2XpVZJRXKH75lKO5j%Y)4znB>z4HF2}wASG^4qaC<+o!G)A|`CUkSP9}Uz1MW}?P?v8(_)XUAWxY@{=ZQNsy$G&4T zUmV{()myq*UN!!St6VkPjQ<|J>M5K4bk1zfJuSD@jg5)MMm~Cd**Z7h|}v5jc6}a33xJv`luoQBh-zXqirwP%)!q|(AWrk8ljTjS~-7himb8gDK4+GekJx+AU53b(~sS7V>P*69Qj zn_j%>rWZ|oTEba+u5yXf<*`c;=43%#0n!70I@2av`f|XWZ zbE$XpjK$5nvfsBuUCoP^?m#g_Ii6SVRA<5)dos$A2zDWVfnY!ZFhQ9j7U+gJ zie(47QT;yglkkt(GMklTbe7U$NeMOhmPslmqb$;r9C zwe{|@U{6CZ7O<9w>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|^~I;F`d2kq$@qHbt_lj6pl<;IepFfYdix7V!GI|VbcMF1vMTqtPU}hD z7QemsMbF`DHaky0lQ*2Ve$~4dU;I#g?Sd3`#3d9DYrn26Jr9IoX zH+Va0h1|{v;SJ3U0Gs%^Oz!=dM0v_mU(s4KVZ-hP*2uz{E#~eWvu5q+Hn+@N7_lzc zyTpClW55i97n2T 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|8o1q3g5fQRmiFGH#0a#xn71>fw@HIXOS8)%rlrQ&7>O3Hg$utVb>lC*VQ~hJuqK8L; z>=MbY80A!l5BTNqdv@C6skJS&tpj&ED$5l6YsMM;MzkkvRy68!P$>b77q`$@ zDer`YQ%Erg^SRIm$c~N!z>anUp9gi^h0`9D^_P0ZtbUTy;I z!j_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^IkOVnS_3m814EY48(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=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;X1Dk78|Zs{C&`eVkM~U%BBokl2KhAK_=?2}%P99NO`|MInVeDk5oy#0j6VP# zC8rNGdBJr6RwV%<3?XzWGK+-bAs-2tQuH+|CuBotYk*vou&1A+uZLh&sFnVx8Yg5;V3)9?r_-8pmIQ|l@zf(@TN8QaOT$rTmhU)^u6@GrtHJRkh` zypIZKynuW{L_Zg<4?^#i9;De7xI&4BmbeU1&XquOKf?TPZ0%q(f;uTdBAgU zvxF#{4||Ujx+1n{1H$8vsL#SzTY_FbE7!c-5 zl047w)Vje=-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?Nue!^=4On)L0Ut22WL7cj1NCbjz(;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+ESaEfVsDgu$l$W87n+U7r_q zS1Edb2jm|bmYli-4j{Av+a~cSYKf3r(24;gtJ;GqAR{lIE>XDEXUmmYoVS#e+h!Xd zM9e1Zte+7|8ST^iO$i1hXms3Cu>; z1?=c14$Me^n;M{>2b-kpNZ8M2+9%%{~mPW&|g(dfsF&e4xa z|Ja|^%Tzb8P#<{EEu7+{)&S{*22SSPB>>$RnMYj+HxCR4szID#N^nG0Mgqu7P&EFn{BziyKJ?o;uXj*gC&hdnrxDN1a8tfiX!x{!o2T z?GhuY`ybn6JTp$qAsZgkJ1IAFs zWCvx|*G(e`;5%yvl|ZUnSYI_>hu3E{er~g&mH(2V1KuJJa7_&n^Bwu4y|lzc=c{dY z`(Kn>#37y$hdE(O#|MR0M$PD%n;RB@98ASGQiFO4nnaJZ9{=+SG+>GF_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#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%DX63rMnCo}+Y&_bAjv^robEjjD-!RCtjOREWrj zGBKb365~LWGE`~#G?&(URF5OXub)QckTIt7JXpkUC2`> zbgnF$A2~wBb|!oBulrBq0h`T+s-f1Y#4e6&lxE}0_6j@4rN|cj^-Jk3;ogS9vTwc9oN}Rmu%Z{TflSL*JvzEcmb82 z{Bgd+2xZRO= zgvAciHx!Mw1VZG8a~YBHc{mMOmZ{!1Yu0McQz;CRowecQuQpMbn~|o6D56E90;c7o z9O><&NSw!}Yfks}Ia;(Tz42gq+3Gb^-%<}yeamu4OEn*A{HaPpV~|3b`>6Vy#?`oO(}J9>bsA~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={U_lK3j`$y`9P(3(CEwCaf z;Bnh>%okdUKLCHy(F|5$S}0=DrDp@@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>d{e0{ zHgB25e)OzaXeqTo6+-hgK%PL7l| zo6-!S*@iA`i`#PBN5jxn7&IF7BW!Y4*lcYi{o=MN#c!j;M<+7lKoRAsqGyZVqg;ab zkoAifY`;HG!+djADrh*XREW|URkfDV*yO!HluUxs>`0&~b-&?$J z;o|o$)I#`6(+1f z3ml<#c)yUfl<<&X@ayn}2;8PI8xr^YF1vk@oMwae$|Jvmk7|&d1cUYrU4)RMY-bQ7 zj)!Hqx1s?g(%Z4Qcl3Vy=xavmn?Z+?&$p9C28zoky**eh(0+VL^5Qym^&_hK$kltUQ>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#ReV08?EHCn@0uQ4Fm*v0BVK6R zICwOHxDoJak*4^B)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-ESC7E-bu~ynyt|#*vSb_Qpr^GBsVqA93n+Bt)fx6T)J|145w- zR;s92vOD36)NwK)8!SFBM&6Gr(7(cp*dZS>AwwGoqKg0zH%2_fgsV+jike>fZHt&< zW>JWPaPG74eymP5K}b*r!A>Ye%vs=!Gy&SP9CbpihO<&z;2c7UeTcI5hkY#o z;A%*slIuVJ`Rk2gV@N_3V@TF~wH(wDTi&=0H)M?AZOWOspDk3Lyvul~I; znQOxmtqf=K?L9p|hC(AL&mLu(##yG2|IOe->hBehn_?_ITnze7To{VT9I-z62=Y@- zTm)Qaq0mtf{>IHKXLK1^$;TJ70*!KC0;7d`$V9oPm}u-GRP-pdk7JAnBUY=6sYsD*-q!DJ54X1n1*~) z+)_rRd>456U*Y5Nc=!0sja#4NWK8C3R8|r?8AndWMx1tV$B0l#^f7sFkLYbM{2^37 z$HXDI@@(+?6&*Y=R z(=yGamHgb3oJ{#mBmHY+Cxd|Pp&}=gk}izQ7e?zxCb~e7@l0Z#K@)67o)C}EeE|Mv!4L!f5=Gn=MQG5d07 zZ?uPlI7(?#%$VM?OVfrGMrzBT?R8Qiiy0IuJDMNe;0(nwh9&SLn>DjH9B}^4$Y@jU za+Pbv2Sz%R*UtqtE&I3F|_v zO1`!*7YXi@=tOP?_P_!ch|TB2OV06!L!I_EOtJ`hPmmDNSOx70-!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?@_&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`cPMfdPXfbe&E% zC2+4bhBL8*S)3U+YW~iOU{!N?lCrCzGd!U>Xx!
%#5t+7yTm^Z{)9pPFdh-)p? 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, <<_::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 +# 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 @@ test "it continues if a user is assigned", %{conn: conn} 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 @@ def build(data \\ %{}) 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 @@ def user_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 @@ def marker_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 @@ test "works with URIs" 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 @@ test "PATCH /api/pleroma/admin/users/:nickname/toggle_activation", %{admin: admi "@#{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 +# 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 +# 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 +# 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 @@ test "redirects with oauth authorization, " <> 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 @@ test "issues a token for `password` grant_type with valid credentials, with full 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 @@ test "returns success result when user already in followers", %{conn: conn} 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 Date: Thu, 7 May 2020 09:13:32 +0000 Subject: [PATCH 75/75] 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 @@ defp chat_enabled?, do: Config.get([:chat, :enabled]) 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 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when 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 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when 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 @@ defp do_create(%{to: to, actor: actor, context: context, object: object} = param _ <- 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 @@ def listen(%{to: to, actor: actor, context: context, object: object} = params) d additional ), {:ok, activity} <- insert(listen_data, local), + _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} end @@ -325,6 +330,7 @@ def accept_or_reject(type, %{to: to, actor: actor, object: object} = params) 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 @@ def update(%{to: to, cc: cc, actor: actor, object: object} = params) 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 @@ defp do_react_with_emoji(user, object, emoji, options) 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 @@ defp do_unreact_with_emoji(user, reaction_id, options) 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 @@ defp do_unlike(actor, object, activity_id, local) 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 @@ defp do_announce(user, object, activity_id, local, public) 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 @@ defp do_unannounce(actor, object, activity_id, local) 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 @@ def follow(follower, followed, activity_id \\ nil, local \\ true) 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 @@ defp do_unfollow(follower, followed, activity_id, local) 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 @@ defp do_block(blocker, blocked, activity_id, local) 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 @@ defp do_unblock(blocker, blocked, activity_id, local) 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 @@ def flag( 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 @@ def move(%User{} = origin, %User{} = target, local \\ true) 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 @@ def init(%{qs: qs} = req, state) 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 @@ def init(%{qs: qs} = req, state) 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 @@ def websocket_handle(_frame, state) 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 @@ def terminate(reason, _req, state) 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 @@ defp expand_topic("list", params) 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 -# 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 -# 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 -# 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 -# 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 -# 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 @@ test "does not create a notification for subscribed users if status is a reply" @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 @@ def build(data \\ %{}, opts \\ %{}) 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 @@ defp ensure_federating_or_authenticated(conn, url, user) 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 -# 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 -# 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 @@ test "it doesn't send notify to the 'user:notification' stream when a user is bl 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 @@ test "it doesn't send notify to the 'user:notification' stream when a thread is } 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 @@ test "it sends follow activities to the 'user:notification' stream", %{ %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 @@ test "works for deletions" 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 @@ test "it doesn't send to user if recipients invalid and thread containment is en ) ) - 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 @@ test "it sends message if recipients invalid and thread containment is disabled" ) ) - 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 @@ test "it sends message if recipients invalid and thread containment is enabled b ) ) - 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 @@ test "it sends conversation update to the 'direct' stream", %{} 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 @@ test "it sends conversation update to the 'direct' stream", %{} 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 @@ test "it sends conversation update to the 'direct' stream when a message is dele {: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