From 6e4f52f8a2e510273149acbaf629521d1b4aec2e Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 16 Oct 2019 16:16:39 +0200 Subject: [PATCH 001/188] Introduce new ingestion pipeline structure, implement internal Likes with it. --- lib/pleroma/web/activity_pub/activity_pub.ex | 35 ++++++++++++ lib/pleroma/web/activity_pub/builder.ex | 43 ++++++++++++++ .../web/activity_pub/object_validator.ex | 57 +++++++++++++++++++ lib/pleroma/web/activity_pub/side_effects.ex | 28 +++++++++ lib/pleroma/web/common_api/common_api.ex | 29 ++++++++-- .../controllers/status_controller.ex | 6 +- test/notification_test.exs | 8 +-- test/object_test.exs | 3 +- test/tasks/database_test.exs | 2 +- test/user_test.exs | 4 +- .../activity_pub/activity_validator_test.exs | 21 +++++++ test/web/activity_pub/side_effects_test.exs | 32 +++++++++++ test/web/activity_pub/transmogrifier_test.exs | 2 +- .../activity_pub/views/object_view_test.exs | 2 +- test/web/common_api/common_api_test.exs | 11 ++-- .../notification_controller_test.exs | 2 +- .../controllers/status_controller_test.exs | 16 +++--- .../views/notification_view_test.exs | 2 +- test/web/ostatus/ostatus_controller_test.exs | 4 +- .../controllers/account_controller_test.exs | 16 +++--- test/web/push/impl_test.exs | 2 +- test/web/streamer/streamer_test.exs | 6 +- 22 files changed, 284 insertions(+), 47 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/builder.ex create mode 100644 lib/pleroma/web/activity_pub/object_validator.ex create mode 100644 lib/pleroma/web/activity_pub/side_effects.ex create mode 100644 test/web/activity_pub/activity_validator_test.exs create mode 100644 test/web/activity_pub/side_effects_test.exs diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 364452b5d..f4fc45926 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -18,6 +18,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.ActivityPub.SideEffects alias Pleroma.Web.Streamer alias Pleroma.Web.WebFinger alias Pleroma.Workers.BackgroundWorker @@ -123,6 +125,38 @@ def increase_poll_votes_if_vote(%{ def increase_poll_votes_if_vote(_create_data), do: :noop + @spec common_pipeline(map(), keyword()) :: {:ok, Activity.t(), keyword()} | {:error, any()} + def common_pipeline(object, meta) do + with {_, {:ok, validated_object, meta}} <- + {:validate_object, ObjectValidator.validate(object, meta)}, + {_, {:ok, mrfd_object}} <- {:mrf_object, MRF.filter(validated_object)}, + {_, {:ok, %Activity{} = activity, meta}} <- + {:persist_object, persist(mrfd_object, meta)}, + {_, {:ok, %Activity{} = activity, meta}} <- + {:execute_side_effects, SideEffects.handle(activity, meta)} do + {:ok, activity, meta} + else + e -> {:error, e} + end + end + + # TODO rewrite in with style + @spec persist(map(), keyword()) :: {:ok, Activity.t() | Object.t()} + def persist(object, meta) do + local = Keyword.get(meta, :local) + {recipients, _, _} = get_recipients(object) + + {:ok, activity} = + Repo.insert(%Activity{ + data: object, + local: local, + recipients: recipients, + actor: object["actor"] + }) + + {:ok, activity, meta} + end + def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when is_map(map) do with nil <- Activity.normalize(map), map <- lazy_put_activity_defaults(map, fake), @@ -130,6 +164,7 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when {_, true} <- {:remote_limit_error, check_remote_limit(map)}, {:ok, map} <- MRF.filter(map), {recipients, _, _} = get_recipients(map), + # ??? {:fake, false, map, recipients} <- {:fake, fake, map, recipients}, :ok <- Containment.contain_child(map), {:ok, map, object} <- insert_full_object(map) do diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex new file mode 100644 index 000000000..1787f1510 --- /dev/null +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -0,0 +1,43 @@ +defmodule Pleroma.Web.ActivityPub.Builder do + @moduledoc """ + This module builds the objects. Meant to be used for creating local objects. + + This module encodes our addressing policies and general shape of our objects. + """ + + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.User + alias Pleroma.Object + + @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"]) + + # Address the actor of the object, and our actor's follower collection if the post is public. + to = + if Visibility.is_public?(object) do + [actor.follower_address, object.data["actor"]] + else + [object.data["actor"]] + end + + # CC everyone who's been addressed in the object, except ourself and the object actor's + # follower collection + cc = + (object.data["to"] ++ (object.data["cc"] || [])) + |> List.delete(actor.ap_id) + |> List.delete(object_actor.follower_address) + + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "actor" => actor.ap_id, + "type" => "Like", + "object" => object.data["id"], + "to" => to, + "cc" => cc, + "context" => object.data["context"] + }, []} + end +end diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex new file mode 100644 index 000000000..8ecad0dec --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -0,0 +1,57 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidator do + @moduledoc """ + This module is responsible for validating an object (which can be an activity) + and checking if it is both well formed and also compatible with our view of + the system. + """ + + alias Pleroma.User + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.Utils + + def validate_id(object, meta) do + with {_, true} <- {:id_presence, Map.has_key?(object, "id")} do + {:ok, object, meta} + else + e -> {:error, e} + end + end + + def validate_actor(object, meta) do + with {_, %User{}} <- {:actor_validation, User.get_cached_by_ap_id(object["actor"])} do + {:ok, object, meta} + else + e -> {:error, e} + end + end + + def common_validations(object, meta) do + with {_, {:ok, object, meta}} <- {:validate_id, validate_id(object, meta)}, + {_, {:ok, object, meta}} <- {:validate_actor, validate_actor(object, meta)} do + {:ok, object, meta} + else + e -> {:error, e} + end + end + + @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} + def validate(object, meta) + + def validate(%{"type" => "Like"} = object, meta) do + with {:ok, object, meta} <- common_validations(object, meta), + {_, %Object{} = liked_object} <- {:find_liked_object, Object.normalize(object["object"])}, + {_, nil} <- {:existing_like, Utils.get_existing_like(object["actor"], liked_object)} do + {:ok, object, meta} + else + e -> {:error, e} + end + end + + def validate(object, meta) do + common_validations(object, meta) + end +end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex new file mode 100644 index 000000000..6d3e77a62 --- /dev/null +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -0,0 +1,28 @@ +defmodule Pleroma.Web.ActivityPub.SideEffects do + @moduledoc """ + This module looks at an inserted object and executes the side effects that it + implies. For example, a `Like` activity will increase the like count on the + liked object, a `Follow` activity will add the user to the follower + collection, and so on. + """ + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Object + alias Pleroma.Notification + + def handle(object, meta \\ []) + + # Tasks this handles: + # - Add like to object + # - Set up notification + def handle(%{data: %{"type" => "Like"}} = object, meta) do + liked_object = Object.get_by_ap_id(object.data["object"]) + Utils.add_like_to_object(object, liked_object) + Notification.create_notifications(object) + {:ok, object, meta} + end + + # Nothing to do + def handle(object, meta) do + {:ok, object, meta} + end +end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 386408d51..466beb724 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.CommonAPI do alias Pleroma.ThreadMute alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility @@ -17,6 +18,7 @@ defmodule Pleroma.Web.CommonAPI do import Pleroma.Web.CommonAPI.Utils require Pleroma.Constants + require Logger def follow(follower, followed) do timeout = Pleroma.Config.get([:activitypub, :follow_handshake_timeout]) @@ -98,16 +100,31 @@ def unrepeat(id_or_ap_id, user) do end end - def favorite(id_or_ap_id, user) do - with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id), - object <- Object.normalize(activity), - nil <- Utils.get_existing_like(user.ap_id, object) do - ActivityPub.like(user, object) + @spec favorite(User.t(), binary()) :: {:ok, Activity.t()} | {:error, any()} + def favorite(%User{} = user, id) do + with {_, %Activity{object: object}} <- {:find_object, Activity.get_by_id_with_object(id)}, + {_, {:ok, like_object, meta}} <- {:build_object, Builder.like(user, object)}, + {_, {:ok, %Activity{} = activity, _meta}} <- + {:common_pipeline, + ActivityPub.common_pipeline(like_object, Keyword.put(meta, :local, true))} do + {:ok, activity} else - _ -> {:error, dgettext("errors", "Could not favorite")} + e -> + Logger.error("Could not favorite #{id}. Error: #{inspect(e, pretty: true)}") + {:error, dgettext("errors", "Could not favorite")} end end + # def favorite(id_or_ap_id, user) do + # with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id), + # object <- Object.normalize(activity), + # nil <- Utils.get_existing_like(user.ap_id, object) do + # ActivityPub.like(user, object) + # else + # _ -> {:error, dgettext("errors", "Could not favorite")} + # end + # end + def unfavorite(id_or_ap_id, user) do with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id) do object = Object.normalize(activity) diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index e5d016f63..4b4482aa8 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -201,9 +201,9 @@ def unreblog(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do end @doc "POST /api/v1/statuses/:id/favourite" - def favourite(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do - with {:ok, _fav, %{data: %{"id" => id}}} <- CommonAPI.favorite(ap_id_or_id, user), - %Activity{} = activity <- Activity.get_create_by_object_ap_id(id) do + def favourite(%{assigns: %{user: user}} = conn, %{"id" => activity_id}) do + with {:ok, _fav} <- CommonAPI.favorite(user, activity_id), + %Activity{} = activity <- Activity.get_by_id(activity_id) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) end end diff --git a/test/notification_test.exs b/test/notification_test.exs index 54c0f9877..940913aa6 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -431,7 +431,7 @@ test "it does not send notification to mentioned users in likes" do "status" => "hey @#{other_user.nickname}!" }) - {:ok, activity_two, _} = CommonAPI.favorite(activity_one.id, third_user) + {:ok, activity_two} = CommonAPI.favorite(third_user, activity_one.id) assert other_user not in Notification.get_notified_from_activity(activity_two) end @@ -461,7 +461,7 @@ test "liking an activity results in 1 notification, then 0 if the activity is de assert Enum.empty?(Notification.for_user(user)) - {:ok, _, _} = CommonAPI.favorite(activity.id, other_user) + {:ok, _} = CommonAPI.favorite(other_user, activity.id) assert length(Notification.for_user(user)) == 1 @@ -478,7 +478,7 @@ test "liking an activity results in 1 notification, then 0 if the activity is un assert Enum.empty?(Notification.for_user(user)) - {:ok, _, _} = CommonAPI.favorite(activity.id, other_user) + {:ok, _} = CommonAPI.favorite(other_user, activity.id) assert length(Notification.for_user(user)) == 1 @@ -533,7 +533,7 @@ test "liking an activity which is already deleted does not generate a notificati assert Enum.empty?(Notification.for_user(user)) - {:error, _} = CommonAPI.favorite(activity.id, other_user) + {:error, _} = CommonAPI.favorite(other_user, activity.id) assert Enum.empty?(Notification.for_user(user)) end diff --git a/test/object_test.exs b/test/object_test.exs index dd228c32f..353bc388d 100644 --- a/test/object_test.exs +++ b/test/object_test.exs @@ -182,7 +182,8 @@ test "preserves internal fields on refetch", %{mock_modified: mock_modified} do user = insert(:user) activity = Activity.get_create_by_object_ap_id(object.data["id"]) - {:ok, _activity, object} = CommonAPI.favorite(activity.id, user) + {:ok, activity} = CommonAPI.favorite(user, activity.id) + object = Object.get_by_ap_id(activity.data["object"]) assert object.data["like_count"] == 1 diff --git a/test/tasks/database_test.exs b/test/tasks/database_test.exs index b63dcac00..c0a313863 100644 --- a/test/tasks/database_test.exs +++ b/test/tasks/database_test.exs @@ -102,7 +102,7 @@ test "it turns OrderedCollection likes into empty arrays" do {:ok, %{id: id, object: object}} = CommonAPI.post(user, %{"status" => "test"}) {:ok, %{object: object2}} = CommonAPI.post(user, %{"status" => "test test"}) - CommonAPI.favorite(id, user2) + CommonAPI.favorite(user2, id) likes = %{ "first" => diff --git a/test/user_test.exs b/test/user_test.exs index 019e7b400..49c1eb02a 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1059,8 +1059,8 @@ test "it deletes a user, all follow relationships and all activities", %{user: u object_two = insert(:note, user: follower) activity_two = insert(:note_activity, user: follower, note: object_two) - {:ok, like, _} = CommonAPI.favorite(activity_two.id, user) - {:ok, like_two, _} = CommonAPI.favorite(activity.id, follower) + {:ok, like} = CommonAPI.favorite(user, activity_two.id) + {:ok, like_two} = CommonAPI.favorite(follower, activity.id) {:ok, repeat, _} = CommonAPI.repeat(activity_two.id, user) {:ok, job} = User.delete(user) diff --git a/test/web/activity_pub/activity_validator_test.exs b/test/web/activity_pub/activity_validator_test.exs new file mode 100644 index 000000000..cb0895a81 --- /dev/null +++ b/test/web/activity_pub/activity_validator_test.exs @@ -0,0 +1,21 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do + use Pleroma.DataCase + + import Pleroma.Factory + + describe "likes" do + test "it is well formed" do + _required_fields = [ + "id", + "actor", + "object" + ] + + _user = insert(:user) + end + end +end diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs new file mode 100644 index 000000000..e505ab4dd --- /dev/null +++ b/test/web/activity_pub/side_effects_test.exs @@ -0,0 +1,32 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.SideEffectsTest do + use Pleroma.DataCase + alias Pleroma.Object + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.SideEffects + + import Pleroma.Factory + describe "like objects" do + setup do + user = insert(:user) + {:ok, post} = CommonAPI.post(user, %{"status" => "hey"}) + + {:ok, like_data, _meta} = Builder.like(user, post.object) + {:ok, like, _meta} = ActivityPub.persist(like_data, []) + + %{like: like, user: user} + end + + test "add the like to the original object", %{like: like, user: user} do + {:ok, like, _} = SideEffects.handle(like) + object = Object.get_by_ap_id(like.data["object"]) + assert object.data["like_count"] == 1 + assert user.ap_id in object.data["likes"] + end + end +end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 6c35a6f4d..28edc5508 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -1187,7 +1187,7 @@ test "it translates ostatus IDs to external URLs" do user = insert(:user) - {:ok, activity, _} = CommonAPI.favorite(referent_activity.id, user) + {:ok, activity} = CommonAPI.favorite(user, referent_activity.id) {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) assert modified["object"] == "http://gs.example.org:4040/index.php/notice/29" diff --git a/test/web/activity_pub/views/object_view_test.exs b/test/web/activity_pub/views/object_view_test.exs index 13447dc29..998247c5c 100644 --- a/test/web/activity_pub/views/object_view_test.exs +++ b/test/web/activity_pub/views/object_view_test.exs @@ -41,7 +41,7 @@ test "renders a like activity" do object = Object.normalize(note) user = insert(:user) - {:ok, like_activity, _} = CommonAPI.favorite(note.id, user) + {:ok, like_activity} = CommonAPI.favorite(user, note.id) result = ObjectView.render("object.json", %{object: like_activity}) diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 83df44c36..d46a361c5 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -251,9 +251,12 @@ test "favoriting a status" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) + {:ok, post_activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) - {:ok, %Activity{}, _} = CommonAPI.favorite(activity.id, user) + {:ok, %Activity{data: data}} = CommonAPI.favorite(user, post_activity.id) + assert data["type"] == "Like" + assert data["actor"] == user.ap_id + assert data["object"] == post_activity.data["object"] end test "retweeting a status twice returns an error" do @@ -270,8 +273,8 @@ test "favoriting a status twice returns an error" do other_user = insert(:user) {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) - {:ok, %Activity{}, _object} = CommonAPI.favorite(activity.id, user) - {:error, _} = CommonAPI.favorite(activity.id, user) + {:ok, %Activity{}} = CommonAPI.favorite(user, activity.id) + {:error, _} = CommonAPI.favorite(user, activity.id) end end diff --git a/test/web/mastodon_api/controllers/notification_controller_test.exs b/test/web/mastodon_api/controllers/notification_controller_test.exs index e4137e92c..6eadccb8e 100644 --- a/test/web/mastodon_api/controllers/notification_controller_test.exs +++ b/test/web/mastodon_api/controllers/notification_controller_test.exs @@ -143,7 +143,7 @@ test "filters notifications using exclude_types", %{conn: conn} do {:ok, mention_activity} = CommonAPI.post(other_user, %{"status" => "hey @#{user.nickname}"}) {:ok, create_activity} = CommonAPI.post(user, %{"status" => "hey"}) - {:ok, favorite_activity, _} = CommonAPI.favorite(create_activity.id, other_user) + {:ok, favorite_activity} = CommonAPI.favorite(other_user, create_activity.id) {:ok, reblog_activity, _} = CommonAPI.repeat(create_activity.id, other_user) {:ok, _, _, follow_activity} = CommonAPI.follow(other_user, user) diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index 2de2725e0..1414d9fed 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -589,7 +589,7 @@ test "reblogged status for another user", %{conn: conn} do user1 = insert(:user) user2 = insert(:user) user3 = insert(:user) - CommonAPI.favorite(activity.id, user2) + {:ok, _} = CommonAPI.favorite(user2, activity.id) {:ok, _bookmark} = Pleroma.Bookmark.create(user2.id, activity.id) {:ok, reblog_activity1, _object} = CommonAPI.repeat(activity.id, user1) {:ok, _, _object} = CommonAPI.repeat(activity.id, user2) @@ -695,7 +695,7 @@ test "unfavorites a status and returns it", %{conn: conn} do activity = insert(:note_activity) user = insert(:user) - {:ok, _, _} = CommonAPI.favorite(activity.id, user) + {:ok, _} = CommonAPI.favorite(user, activity.id) conn = conn @@ -1047,7 +1047,7 @@ test "Repeated posts that are replies incorrectly have in_reply_to_id null", %{c test "returns users who have favorited the status", %{conn: conn, activity: activity} do other_user = insert(:user) - {:ok, _, _} = CommonAPI.favorite(activity.id, other_user) + {:ok, _} = CommonAPI.favorite(other_user, activity.id) response = conn @@ -1078,7 +1078,7 @@ test "does not return users who have favorited the status but are blocked", %{ other_user = insert(:user) {:ok, user} = User.block(user, other_user) - {:ok, _, _} = CommonAPI.favorite(activity.id, other_user) + {:ok, _} = CommonAPI.favorite(other_user, activity.id) response = conn @@ -1091,7 +1091,7 @@ test "does not return users who have favorited the status but are blocked", %{ test "does not fail on an unauthenticated request", %{conn: conn, activity: activity} do other_user = insert(:user) - {:ok, _, _} = CommonAPI.favorite(activity.id, other_user) + {:ok, _} = CommonAPI.favorite(other_user, activity.id) response = conn @@ -1112,7 +1112,7 @@ test "requires authentification for private posts", %{conn: conn, user: user} do "visibility" => "direct" }) - {:ok, _, _} = CommonAPI.favorite(activity.id, other_user) + {:ok, _} = CommonAPI.favorite(other_user, activity.id) conn |> assign(:user, nil) @@ -1269,7 +1269,7 @@ test "returns the favorites of a user", %{conn: conn} do {:ok, _} = CommonAPI.post(other_user, %{"status" => "bla"}) {:ok, activity} = CommonAPI.post(other_user, %{"status" => "traps are happy"}) - {:ok, _, _} = CommonAPI.favorite(activity.id, user) + {:ok, _} = CommonAPI.favorite(user, activity.id) first_conn = conn @@ -1289,7 +1289,7 @@ test "returns the favorites of a user", %{conn: conn} do "Trees Are Never Sad Look At Them Every Once In Awhile They're Quite Beautiful." }) - {:ok, _, _} = CommonAPI.favorite(second_activity.id, user) + {:ok, _} = CommonAPI.favorite(user, second_activity.id) last_like = status["id"] diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index c9043a69a..d06809268 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -42,7 +42,7 @@ test "Favourite notification" do user = insert(:user) another_user = insert(:user) {:ok, create_activity} = CommonAPI.post(user, %{"status" => "hey"}) - {:ok, favorite_activity, _object} = CommonAPI.favorite(create_activity.id, another_user) + {:ok, favorite_activity} = CommonAPI.favorite(another_user, create_activity.id) {:ok, [notification]} = Notification.create_notifications(favorite_activity) create_activity = Activity.get_by_id(create_activity.id) diff --git a/test/web/ostatus/ostatus_controller_test.exs b/test/web/ostatus/ostatus_controller_test.exs index b1af918d8..7aee16e2c 100644 --- a/test/web/ostatus/ostatus_controller_test.exs +++ b/test/web/ostatus/ostatus_controller_test.exs @@ -271,7 +271,7 @@ test "only gets a notice in AS2 format for Create messages", %{conn: conn} do user = insert(:user) - {:ok, like_activity, _} = CommonAPI.favorite(note_activity.id, user) + {:ok, like_activity} = CommonAPI.favorite(user, note_activity.id) url = "/notice/#{like_activity.id}" assert like_activity.data["type"] == "Like" @@ -298,7 +298,7 @@ test "render html for redirect for html format", %{conn: conn} do user = insert(:user) - {:ok, like_activity, _} = CommonAPI.favorite(note_activity.id, user) + {:ok, like_activity} = CommonAPI.favorite(user, note_activity.id) assert like_activity.data["type"] == "Like" diff --git a/test/web/pleroma_api/controllers/account_controller_test.exs b/test/web/pleroma_api/controllers/account_controller_test.exs index 3b4665afd..6a6135d02 100644 --- a/test/web/pleroma_api/controllers/account_controller_test.exs +++ b/test/web/pleroma_api/controllers/account_controller_test.exs @@ -165,7 +165,7 @@ test "returns list of statuses favorited by specified user", %{ user: user } do [activity | _] = insert_pair(:note_activity) - CommonAPI.favorite(activity.id, user) + CommonAPI.favorite(user, activity.id) response = conn @@ -184,7 +184,7 @@ test "returns favorites for specified user_id when user is not logged in", %{ user: user } do activity = insert(:note_activity) - CommonAPI.favorite(activity.id, user) + CommonAPI.favorite(user, activity.id) response = conn @@ -205,7 +205,7 @@ test "returns favorited DM only when user is logged in and he is one of recipien "visibility" => "direct" }) - CommonAPI.favorite(direct.id, user) + CommonAPI.favorite(user, direct.id) response = conn @@ -236,7 +236,7 @@ test "does not return others' favorited DM when user is not one of recipients", "visibility" => "direct" }) - CommonAPI.favorite(direct.id, user) + CommonAPI.favorite(user, direct.id) response = conn @@ -255,7 +255,7 @@ test "paginates favorites using since_id and max_id", %{ activities = insert_list(10, :note_activity) Enum.each(activities, fn activity -> - CommonAPI.favorite(activity.id, user) + CommonAPI.favorite(user, activity.id) end) third_activity = Enum.at(activities, 2) @@ -283,7 +283,7 @@ test "limits favorites using limit parameter", %{ 7 |> insert_list(:note_activity) |> Enum.each(fn activity -> - CommonAPI.favorite(activity.id, user) + CommonAPI.favorite(user, activity.id) end) response = @@ -321,7 +321,7 @@ test "returns 403 error when user has hidden own favorites", %{ } do user = insert(:user, %{info: %{hide_favorites: true}}) activity = insert(:note_activity) - CommonAPI.favorite(activity.id, user) + CommonAPI.favorite(user, activity.id) conn = conn @@ -334,7 +334,7 @@ test "returns 403 error when user has hidden own favorites", %{ test "hides favorites for new users by default", %{conn: conn, current_user: current_user} do user = insert(:user) activity = insert(:note_activity) - CommonAPI.favorite(activity.id, user) + CommonAPI.favorite(user, activity.id) conn = conn diff --git a/test/web/push/impl_test.exs b/test/web/push/impl_test.exs index 2f6ce4bd2..36c69c7c9 100644 --- a/test/web/push/impl_test.exs +++ b/test/web/push/impl_test.exs @@ -152,7 +152,7 @@ test "renders body for like activity" do "Lorem ipsum dolor sit amet, consectetur :firefox: adipiscing elit. Fusce sagittis finibus turpis." }) - {:ok, activity, _} = CommonAPI.favorite(activity.id, user) + {:ok, activity} = CommonAPI.favorite(user, activity.id) object = Object.normalize(activity) assert Impl.format_body(%{activity: activity}, user, object) == "@Bob has favorited your post" diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index d33eb1e42..b363935a2 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -68,7 +68,7 @@ test "it doesn't send notify to the 'user:notification' stream when a user is bl ) {:ok, activity} = CommonAPI.post(user, %{"status" => ":("}) - {:ok, notif, _} = CommonAPI.favorite(activity.id, blocked) + {:ok, notif} = CommonAPI.favorite(blocked, activity.id) Streamer.stream("user:notification", notif) Task.await(task) @@ -87,7 +87,7 @@ test "it doesn't send notify to the 'user:notification' stream when a thread is {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"}) {:ok, activity} = CommonAPI.add_mute(user, activity) - {:ok, notif, _} = CommonAPI.favorite(activity.id, user2) + {:ok, notif} = CommonAPI.favorite(user2, activity.id) Streamer.stream("user:notification", notif) Task.await(task) end @@ -105,7 +105,7 @@ test "it doesn't send notify to the 'user:notification' stream' when a domain is {:ok, user} = User.block_domain(user, "hecking-lewd-place.com") {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"}) - {:ok, notif, _} = CommonAPI.favorite(activity.id, user2) + {:ok, notif} = CommonAPI.favorite(user2, activity.id) Streamer.stream("user:notification", notif) Task.await(task) From 081e8206ab75e336a76b621508b3999170159ec6 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 16 Oct 2019 17:03:21 +0200 Subject: [PATCH 002/188] Transmogrifier: Use new ingestion pipeline for Likes. --- lib/pleroma/object/containment.ex | 12 +++++++ .../web/activity_pub/object_validator.ex | 5 +-- .../web/activity_pub/transmogrifier.ex | 31 +++++++++++++++---- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/object/containment.ex b/lib/pleroma/object/containment.ex index f077a9f32..edbe92381 100644 --- a/lib/pleroma/object/containment.ex +++ b/lib/pleroma/object/containment.ex @@ -32,6 +32,18 @@ def get_actor(%{"actor" => nil, "attributedTo" => actor}) when not is_nil(actor) get_actor(%{"actor" => actor}) end + def get_object(%{"object" => id}) when is_binary(id) do + id + end + + def get_object(%{"object" => %{"id" => id}}) when is_binary(id) do + id + end + + def get_object(_) do + nil + end + @doc """ Checks that an imported AP object's actor matches the domain it came from. """ diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 8ecad0dec..0048cc4ec 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -31,7 +31,7 @@ def validate_actor(object, meta) do def common_validations(object, meta) do with {_, {:ok, object, meta}} <- {:validate_id, validate_id(object, meta)}, - {_, {:ok, object, meta}} <- {:validate_actor, validate_actor(object, meta)} do + {_, {:ok, object, meta}} <- {:validate_actor, validate_actor(object, meta)} do {:ok, object, meta} else e -> {:error, e} @@ -43,7 +43,8 @@ def validate(object, meta) def validate(%{"type" => "Like"} = object, meta) do with {:ok, object, meta} <- common_validations(object, meta), - {_, %Object{} = liked_object} <- {:find_liked_object, Object.normalize(object["object"])}, + {_, %Object{} = liked_object} <- + {:find_liked_object, Object.normalize(object["object"])}, {_, nil} <- {:existing_like, Utils.get_existing_like(object["actor"], liked_object)} do {:ok, object, meta} else diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index b56343beb..3e982adcb 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -563,19 +563,38 @@ def handle_incoming( end def handle_incoming( - %{"type" => "Like", "object" => object_id, "actor" => _actor, "id" => id} = data, + %{"type" => "Like", "object" => _object_id, "actor" => _actor, "id" => _id} = data, _options ) do - 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, activity, _object} <- ActivityPub.like(actor, object, id, false) do + with data <- Map.take(data, ["type", "object", "actor", "context", "id"]), + actor <- Containment.get_actor(data), + object <- Containment.get_object(data), + data <- data |> Map.put("actor", actor) |> Map.put("object", object), + _user <- User.get_or_fetch_by_ap_id(actor), + object <- Object.normalize(object), + data <- Map.put_new(data, "context", object.data["context"]), + {_, {:ok, activity, _meta}} <- + {:common_pipeline, ActivityPub.common_pipeline(data, local: false)} do {:ok, activity} else - _e -> :error + e -> {:error, e} end end + # def handle_incoming( + # %{"type" => "Like", "object" => object_id, "actor" => _actor, "id" => id} = data, + # _options + # ) do + # 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, activity, _object} <- ActivityPub.like(actor, object, id, false) do + # {:ok, activity} + # else + # _e -> :error + # end + # end + def handle_incoming( %{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => id} = data, _options From 66452f518faa1f079f02006943b0c2cdc830b47f Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 17 Oct 2019 18:36:52 +0200 Subject: [PATCH 003/188] ObjectValidator: Rewrite LikeValidator with Ecto. --- .../web/activity_pub/object_validator.ex | 42 ++-------- .../object_validators/like_validator.ex | 69 ++++++++++++++++ .../object_validators/types/object.ex | 25 ++++++ lib/pleroma/web/common_api/common_api.ex | 10 --- .../activity_pub/activity_validator_test.exs | 21 ----- .../activity_pub/object_validator_test.exs | 80 +++++++++++++++++++ test/web/activity_pub/side_effects_test.exs | 1 + 7 files changed, 183 insertions(+), 65 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/object_validators/like_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/types/object.ex delete mode 100644 test/web/activity_pub/activity_validator_test.exs create mode 100644 test/web/activity_pub/object_validator_test.exs diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 0048cc4ec..adcb53c65 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -9,50 +9,24 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do the system. """ - alias Pleroma.User - alias Pleroma.Object - alias Pleroma.Web.ActivityPub.Utils - - def validate_id(object, meta) do - with {_, true} <- {:id_presence, Map.has_key?(object, "id")} do - {:ok, object, meta} - else - e -> {:error, e} - end - end - - def validate_actor(object, meta) do - with {_, %User{}} <- {:actor_validation, User.get_cached_by_ap_id(object["actor"])} do - {:ok, object, meta} - else - e -> {:error, e} - end - end - - def common_validations(object, meta) do - with {_, {:ok, object, meta}} <- {:validate_id, validate_id(object, meta)}, - {_, {:ok, object, meta}} <- {:validate_actor, validate_actor(object, meta)} do - {:ok, object, meta} - else - e -> {:error, e} - end - end + alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) def validate(%{"type" => "Like"} = object, meta) do - with {:ok, object, meta} <- common_validations(object, meta), - {_, %Object{} = liked_object} <- - {:find_liked_object, Object.normalize(object["object"])}, - {_, nil} <- {:existing_like, Utils.get_existing_like(object["actor"], liked_object)} do + with {_, %{valid?: true, changes: object}} <- + {:validate_object, LikeValidator.cast_and_validate(object)} do + object = stringify_keys(object) {:ok, object, meta} else e -> {:error, e} end end - def validate(object, meta) do - common_validations(object, meta) + defp stringify_keys(object) do + object + |> Enum.map(fn {key, val} -> {to_string(key), val} end) + |> Enum.into(%{}) end end diff --git a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex new file mode 100644 index 000000000..d5a2f7202 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex @@ -0,0 +1,69 @@ +defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator do + use Ecto.Schema + import Ecto.Changeset + + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.User + alias Pleroma.Object + + @primary_key false + + embedded_schema do + field(:id, :string, primary_key: true) + field(:type, :string) + field(:object, Types.ObjectID) + field(:actor, Types.ObjectID) + field(:context, :string) + field(:to, {:array, :string}) + field(:cc, {:array, :string}) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> cast(data, [:id, :type, :object, :actor, :context, :to, :cc]) + end + + def validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["Like"]) + |> validate_required([:id, :type, :object, :actor, :context]) + |> validate_change(:actor, &actor_valid?/2) + |> validate_change(:object, &object_valid?/2) + |> validate_existing_like() + end + + def validate_existing_like(%{changes: %{actor: actor, object: object}} = cng) do + if Utils.get_existing_like(actor, %{data: %{"id" => object}}) do + cng + |> add_error(:actor, "already liked this object") + |> add_error(:object, "already liked by this actor") + else + cng + end + end + + def validate_existing_like(cng), do: cng + + def actor_valid?(field_name, actor) do + if User.get_cached_by_ap_id(actor) do + [] + else + [{field_name, "can't find user"}] + end + end + + def object_valid?(field_name, object) do + if Object.get_cached_by_ap_id(object) do + [] + else + [{field_name, "can't find object"}] + end + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/types/object.ex b/lib/pleroma/web/activity_pub/object_validators/types/object.ex new file mode 100644 index 000000000..92fc13ba8 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/types/object.ex @@ -0,0 +1,25 @@ +defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID do + use Ecto.Type + + def type, do: :string + + def cast(object) when is_binary(object) do + {:ok, object} + end + + def cast(%{"id" => object}) when is_binary(object) do + {:ok, object} + end + + def cast(_) do + :error + end + + def dump(data) do + {:ok, data} + end + + def load(data) do + {:ok, data} + end +end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 466beb724..e0b22a314 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -115,16 +115,6 @@ def favorite(%User{} = user, id) do end end - # def favorite(id_or_ap_id, user) do - # with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id), - # object <- Object.normalize(activity), - # nil <- Utils.get_existing_like(user.ap_id, object) do - # ActivityPub.like(user, object) - # else - # _ -> {:error, dgettext("errors", "Could not favorite")} - # end - # end - def unfavorite(id_or_ap_id, user) do with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id) do object = Object.normalize(activity) diff --git a/test/web/activity_pub/activity_validator_test.exs b/test/web/activity_pub/activity_validator_test.exs deleted file mode 100644 index cb0895a81..000000000 --- a/test/web/activity_pub/activity_validator_test.exs +++ /dev/null @@ -1,21 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do - use Pleroma.DataCase - - import Pleroma.Factory - - describe "likes" do - test "it is well formed" do - _required_fields = [ - "id", - "actor", - "object" - ] - - _user = insert(:user) - end - end -end diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs new file mode 100644 index 000000000..374a7c0df --- /dev/null +++ b/test/web/activity_pub/object_validator_test.exs @@ -0,0 +1,80 @@ +defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do + use Pleroma.DataCase + + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator + alias Pleroma.Web.ActivityPub.Utils + import Pleroma.Factory + + describe "likes" do + setup do + user = insert(:user) + {:ok, post_activity} = CommonAPI.post(user, %{"status" => "uguu"}) + + valid_like = %{ + "type" => "Like", + "id" => Utils.generate_activity_id(), + "object" => post_activity.data["object"], + "actor" => user.ap_id, + "context" => "a context" + } + + %{valid_like: valid_like, user: user, post_activity: post_activity} + end + + test "returns ok when called in the ObjectValidator", %{valid_like: valid_like} do + {:ok, object, _meta} = ObjectValidator.validate(valid_like, []) + + assert "id" in Map.keys(object) + end + + test "is valid for a valid object", %{valid_like: valid_like} do + assert LikeValidator.cast_and_validate(valid_like).valid? + end + + test "it errors when the actor is missing or not known", %{valid_like: valid_like} do + without_actor = Map.delete(valid_like, "actor") + + refute LikeValidator.cast_and_validate(without_actor).valid? + + with_invalid_actor = Map.put(valid_like, "actor", "invalidactor") + + refute LikeValidator.cast_and_validate(with_invalid_actor).valid? + end + + test "it errors when the object is missing or not known", %{valid_like: valid_like} do + without_object = Map.delete(valid_like, "object") + + refute LikeValidator.cast_and_validate(without_object).valid? + + with_invalid_object = Map.put(valid_like, "object", "invalidobject") + + refute LikeValidator.cast_and_validate(with_invalid_object).valid? + end + + test "it errors when the actor has already like the object", %{ + valid_like: valid_like, + user: user, + post_activity: post_activity + } do + _like = CommonAPI.favorite(user, post_activity.id) + + refute LikeValidator.cast_and_validate(valid_like).valid? + end + + test "it works when actor or object are wrapped in maps", %{valid_like: valid_like} do + wrapped_like = + valid_like + |> Map.put("actor", %{"id" => valid_like["actor"]}) + |> Map.put("object", %{"id" => valid_like["object"]}) + + validated = LikeValidator.cast_and_validate(wrapped_like) + + assert validated.valid? + + assert {:actor, valid_like["actor"]} in validated.changes + assert {:object, valid_like["object"]} in validated.changes + end + end +end diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index e505ab4dd..9d99e05a0 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do alias Pleroma.Web.ActivityPub.SideEffects import Pleroma.Factory + describe "like objects" do setup do user = insert(:user) From 203d61b95012fd2cb8a8618f6f51a3748c940cc1 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 17 Oct 2019 19:35:31 +0200 Subject: [PATCH 004/188] Transmogrifier: Make proper use of the LikeValidator. --- .../web/activity_pub/object_validator.ex | 10 ++- .../object_validators/like_validator.ex | 2 +- .../web/activity_pub/transmogrifier.ex | 79 +++++++++++++------ .../activity_pub/object_validator_test.exs | 2 + test/web/activity_pub/transmogrifier_test.exs | 4 +- 5 files changed, 68 insertions(+), 29 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index adcb53c65..33e67dbb9 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -10,6 +10,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do """ alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator + alias Pleroma.User + alias Pleroma.Object @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) @@ -24,9 +26,15 @@ def validate(%{"type" => "Like"} = object, meta) do end end - defp stringify_keys(object) do + def stringify_keys(object) do object |> Enum.map(fn {key, val} -> {to_string(key), val} end) |> Enum.into(%{}) end + + def fetch_actor_and_object(object) do + User.get_or_fetch_by_ap_id(object["actor"]) + Object.normalize(object["object"]) + :ok + end end 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 d5a2f7202..e6a5aaca8 100644 --- a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex @@ -33,7 +33,7 @@ def cast_data(data) do def validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["Like"]) - |> validate_required([:id, :type, :object, :actor, :context]) + |> validate_required([:id, :type, :object, :actor, :context, :to, :cc]) |> validate_change(:actor, &actor_valid?/2) |> validate_change(:object, &object_valid?/2) |> validate_existing_like() diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 3e982adcb..591d7aa94 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -16,6 +16,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.Federator alias Pleroma.Workers.TransmogrifierWorker + alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator import Ecto.Query @@ -562,39 +564,21 @@ def handle_incoming( end end - def handle_incoming( - %{"type" => "Like", "object" => _object_id, "actor" => _actor, "id" => _id} = data, - _options - ) do - with data <- Map.take(data, ["type", "object", "actor", "context", "id"]), - actor <- Containment.get_actor(data), - object <- Containment.get_object(data), - data <- data |> Map.put("actor", actor) |> Map.put("object", object), - _user <- User.get_or_fetch_by_ap_id(actor), - object <- Object.normalize(object), - data <- Map.put_new(data, "context", object.data["context"]), + def handle_incoming(%{"type" => "Like"} = data, _options) do + with {_, %{changes: cast_data}} <- {:casting_data, LikeValidator.cast_data(data)}, + cast_data <- ObjectValidator.stringify_keys(cast_data), + :ok <- ObjectValidator.fetch_actor_and_object(cast_data), + {_, {:ok, cast_data}} <- {:maybe_add_context, maybe_add_context_from_object(cast_data)}, + {_, {:ok, cast_data}} <- + {:maybe_add_recipients, maybe_add_recipients_from_object(cast_data)}, {_, {:ok, activity, _meta}} <- - {:common_pipeline, ActivityPub.common_pipeline(data, local: false)} do + {:common_pipeline, ActivityPub.common_pipeline(cast_data, local: false)} do {:ok, activity} else e -> {:error, e} end end - # def handle_incoming( - # %{"type" => "Like", "object" => object_id, "actor" => _actor, "id" => id} = data, - # _options - # ) do - # 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, activity, _object} <- ActivityPub.like(actor, object, id, false) do - # {:ok, activity} - # else - # _e -> :error - # end - # end - def handle_incoming( %{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => id} = data, _options @@ -1156,4 +1140,47 @@ def maybe_fix_user_url(%{"url" => url} = data) when is_map(url) do def maybe_fix_user_url(data), do: data def maybe_fix_user_object(data), do: maybe_fix_user_url(data) + + defp maybe_add_context_from_object(%{"context" => context} = data) when is_binary(context), + do: {:ok, data} + + defp maybe_add_context_from_object(%{"object" => object} = data) when is_binary(object) do + if object = Object.normalize(object) do + data = + data + |> Map.put("context", object.data["context"]) + + {:ok, data} + else + {:error, "No context on referenced object"} + end + end + + defp maybe_add_context_from_object(_) do + {:error, "No referenced object"} + end + + defp maybe_add_recipients_from_object(%{"object" => object} = data) do + to = data["to"] || [] + cc = data["cc"] || [] + + if to == [] && cc == [] do + if object = Object.normalize(object) do + data = + data + |> Map.put("to", [object.data["actor"]]) + |> Map.put("cc", cc) + + {:ok, data} + else + {:error, "No actor on referenced object"} + end + else + {:ok, data} + end + end + + defp maybe_add_recipients_from_object(_) do + {:error, "No referenced object"} + end end diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 374a7c0df..2292db6d7 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -13,6 +13,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do {:ok, post_activity} = CommonAPI.post(user, %{"status" => "uguu"}) valid_like = %{ + "to" => [user.ap_id], + "cc" => [], "type" => "Like", "id" => Utils.generate_activity_id(), "object" => post_activity.data["object"], diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 28edc5508..e5d4dcd64 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -333,7 +333,9 @@ test "it works for incoming likes" do |> Poison.decode!() |> Map.put("object", activity.data["object"]) - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + {:ok, %Activity{data: data, local: false} = activity} = Transmogrifier.handle_incoming(data) + + refute Enum.empty?(activity.recipients) assert data["actor"] == "http://mastodon.example.org/users/admin" assert data["type"] == "Like" From 4ec299ea9c1cf45c42e98d7b33f33a72f5e7a9c0 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 18 Oct 2019 12:11:25 +0200 Subject: [PATCH 005/188] CommonAPI tests: Capture logs. --- test/web/common_api/common_api_test.exs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index d46a361c5..63d7ea79f 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -13,6 +13,7 @@ defmodule Pleroma.Web.CommonAPITest do alias Pleroma.Web.CommonAPI import Pleroma.Factory + import ExUnit.CaptureLog require Pleroma.Constants @@ -274,7 +275,9 @@ test "favoriting a status twice returns an error" do {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) {:ok, %Activity{}} = CommonAPI.favorite(user, activity.id) - {:error, _} = CommonAPI.favorite(user, activity.id) + assert capture_log(fn -> + assert {:error, _} = CommonAPI.favorite(user, activity.id) + end) =~ "[error]" end end From 15bbc34c079018f1c988fe9d445bec50e85bbeaf Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 18 Oct 2019 12:44:53 +0200 Subject: [PATCH 006/188] Tests: Capture log. --- test/notification_test.exs | 6 +++++- test/web/common_api/common_api_test.exs | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/test/notification_test.exs b/test/notification_test.exs index 940913aa6..480c9415b 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -14,6 +14,8 @@ defmodule Pleroma.NotificationTest do alias Pleroma.Web.CommonAPI alias Pleroma.Web.Streamer + import ExUnit.CaptureLog + describe "create_notifications" do test "notifies someone when they are directly addressed" do user = insert(:user) @@ -533,7 +535,9 @@ test "liking an activity which is already deleted does not generate a notificati assert Enum.empty?(Notification.for_user(user)) - {:error, _} = CommonAPI.favorite(other_user, activity.id) + assert capture_log(fn -> + {:error, _} = CommonAPI.favorite(other_user, activity.id) + end) =~ "[error]" assert Enum.empty?(Notification.for_user(user)) end diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 63d7ea79f..8195b1910 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -275,9 +275,10 @@ test "favoriting a status twice returns an error" do {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) {:ok, %Activity{}} = CommonAPI.favorite(user, activity.id) + assert capture_log(fn -> - assert {:error, _} = CommonAPI.favorite(user, activity.id) - end) =~ "[error]" + assert {:error, _} = CommonAPI.favorite(user, activity.id) + end) =~ "[error]" end end From f1381d68e740daf4c341359a2b5837bc2bd3a051 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 19 Oct 2019 14:46:14 +0200 Subject: [PATCH 007/188] StatusControllerTest: Capture log. --- .../controllers/status_controller_test.exs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index 1414d9fed..2bbd8a151 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -17,6 +17,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do alias Pleroma.Web.CommonAPI import Pleroma.Factory + import ExUnit.CaptureLog describe "posting statuses" do setup do @@ -681,12 +682,14 @@ test "favs a status and returns it", %{conn: conn} do test "returns 400 error for a wrong id", %{conn: conn} do user = insert(:user) - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses/1/favourite") + assert capture_log(fn -> + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses/1/favourite") - assert json_response(conn, 400) == %{"error" => "Could not favorite"} + assert json_response(conn, 400) == %{"error" => "Could not favorite"} + end) =~ "[error]" end end From 97d5c79aa07bfe836cd676424ce1b5a298c72b60 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 23 Oct 2019 11:52:27 +0200 Subject: [PATCH 008/188] Add Pipeline module, test for federation. --- lib/pleroma/web/activity_pub/activity_pub.ex | 19 +--- lib/pleroma/web/activity_pub/pipeline.ex | 41 +++++++++ .../web/activity_pub/transmogrifier.ex | 7 +- lib/pleroma/web/common_api/common_api.ex | 3 +- test/web/activity_pub/pipeline_test.exs | 87 +++++++++++++++++++ 5 files changed, 135 insertions(+), 22 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/pipeline.ex create mode 100644 test/web/activity_pub/pipeline_test.exs diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index f4fc45926..0789ec31c 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -18,8 +18,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.Utils - alias Pleroma.Web.ActivityPub.ObjectValidator - alias Pleroma.Web.ActivityPub.SideEffects alias Pleroma.Web.Streamer alias Pleroma.Web.WebFinger alias Pleroma.Workers.BackgroundWorker @@ -125,25 +123,10 @@ def increase_poll_votes_if_vote(%{ def increase_poll_votes_if_vote(_create_data), do: :noop - @spec common_pipeline(map(), keyword()) :: {:ok, Activity.t(), keyword()} | {:error, any()} - def common_pipeline(object, meta) do - with {_, {:ok, validated_object, meta}} <- - {:validate_object, ObjectValidator.validate(object, meta)}, - {_, {:ok, mrfd_object}} <- {:mrf_object, MRF.filter(validated_object)}, - {_, {:ok, %Activity{} = activity, meta}} <- - {:persist_object, persist(mrfd_object, meta)}, - {_, {:ok, %Activity{} = activity, meta}} <- - {:execute_side_effects, SideEffects.handle(activity, meta)} do - {:ok, activity, meta} - else - e -> {:error, e} - end - end - # TODO rewrite in with style @spec persist(map(), keyword()) :: {:ok, Activity.t() | Object.t()} def persist(object, meta) do - local = Keyword.get(meta, :local) + local = Keyword.fetch!(meta, :local) {recipients, _, _} = get_recipients(object) {:ok, activity} = diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex new file mode 100644 index 000000000..cb3571917 --- /dev/null +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -0,0 +1,41 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Pipeline do + alias Pleroma.Activity + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.MRF + alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.ActivityPub.SideEffects + alias Pleroma.Web.Federator + + @spec common_pipeline(map(), keyword()) :: {:ok, Activity.t(), keyword()} | {:error, any()} + def common_pipeline(object, meta) do + with {_, {:ok, validated_object, meta}} <- + {:validate_object, ObjectValidator.validate(object, meta)}, + {_, {:ok, mrfd_object}} <- {:mrf_object, MRF.filter(validated_object)}, + {_, {:ok, %Activity{} = activity, meta}} <- + {:persist_object, ActivityPub.persist(mrfd_object, meta)}, + {_, {:ok, %Activity{} = activity, meta}} <- + {:execute_side_effects, SideEffects.handle(activity, meta)}, + {_, {:ok, _}} <- {:federation, maybe_federate(activity, meta)} do + {:ok, activity, meta} + else + e -> {:error, e} + end + end + + defp maybe_federate(activity, meta) do + with {:ok, local} <- Keyword.fetch(meta, :local) do + if local do + Federator.publish(activity) + {:ok, :federated} + else + {:ok, :not_federated} + end + else + _e -> {:error, "local not set in meta"} + end + end +end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 591d7aa94..4dd884ce9 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -12,12 +12,13 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator + alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.Federator alias Pleroma.Workers.TransmogrifierWorker - alias Pleroma.Web.ActivityPub.ObjectValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator import Ecto.Query @@ -572,7 +573,7 @@ def handle_incoming(%{"type" => "Like"} = data, _options) do {_, {:ok, cast_data}} <- {:maybe_add_recipients, maybe_add_recipients_from_object(cast_data)}, {_, {:ok, activity, _meta}} <- - {:common_pipeline, ActivityPub.common_pipeline(cast_data, local: false)} do + {:common_pipeline, Pipeline.common_pipeline(cast_data, local: false)} do {:ok, activity} else e -> {:error, e} diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index e0b22a314..535a48dcc 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Web.CommonAPI do alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility @@ -106,7 +107,7 @@ def favorite(%User{} = user, id) do {_, {:ok, like_object, meta}} <- {:build_object, Builder.like(user, object)}, {_, {:ok, %Activity{} = activity, _meta}} <- {:common_pipeline, - ActivityPub.common_pipeline(like_object, Keyword.put(meta, :local, true))} do + Pipeline.common_pipeline(like_object, Keyword.put(meta, :local, true))} do {:ok, activity} else e -> diff --git a/test/web/activity_pub/pipeline_test.exs b/test/web/activity_pub/pipeline_test.exs new file mode 100644 index 000000000..318d306af --- /dev/null +++ b/test/web/activity_pub/pipeline_test.exs @@ -0,0 +1,87 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.PipelineTest do + use Pleroma.DataCase + + import Mock + import Pleroma.Factory + + describe "common_pipeline/2" do + test "it goes through validation, filtering, persisting, side effects and federation for local activities" do + activity = insert(:note_activity) + meta = [local: true] + + with_mocks([ + {Pleroma.Web.ActivityPub.ObjectValidator, [], [validate: fn o, m -> {:ok, o, m} end]}, + { + Pleroma.Web.ActivityPub.MRF, + [], + [filter: fn o -> {:ok, o} end] + }, + { + Pleroma.Web.ActivityPub.ActivityPub, + [], + [persist: fn o, m -> {:ok, o, m} end] + }, + { + Pleroma.Web.ActivityPub.SideEffects, + [], + [handle: fn o, m -> {:ok, o, m} end] + }, + { + Pleroma.Web.Federator, + [], + [publish: fn _o -> :ok end] + } + ]) do + assert {:ok, ^activity, ^meta} = + Pleroma.Web.ActivityPub.Pipeline.common_pipeline(activity, meta) + + assert_called(Pleroma.Web.ActivityPub.ObjectValidator.validate(activity, meta)) + assert_called(Pleroma.Web.ActivityPub.MRF.filter(activity)) + assert_called(Pleroma.Web.ActivityPub.ActivityPub.persist(activity, meta)) + assert_called(Pleroma.Web.ActivityPub.SideEffects.handle(activity, meta)) + assert_called(Pleroma.Web.Federator.publish(activity)) + end + end + + test "it goes through validation, filtering, persisting, side effects without federation for remote activities" do + activity = insert(:note_activity) + meta = [local: false] + + with_mocks([ + {Pleroma.Web.ActivityPub.ObjectValidator, [], [validate: fn o, m -> {:ok, o, m} end]}, + { + Pleroma.Web.ActivityPub.MRF, + [], + [filter: fn o -> {:ok, o} end] + }, + { + Pleroma.Web.ActivityPub.ActivityPub, + [], + [persist: fn o, m -> {:ok, o, m} end] + }, + { + Pleroma.Web.ActivityPub.SideEffects, + [], + [handle: fn o, m -> {:ok, o, m} end] + }, + { + Pleroma.Web.Federator, + [], + [] + } + ]) do + assert {:ok, ^activity, ^meta} = + Pleroma.Web.ActivityPub.Pipeline.common_pipeline(activity, meta) + + assert_called(Pleroma.Web.ActivityPub.ObjectValidator.validate(activity, meta)) + assert_called(Pleroma.Web.ActivityPub.MRF.filter(activity)) + assert_called(Pleroma.Web.ActivityPub.ActivityPub.persist(activity, meta)) + assert_called(Pleroma.Web.ActivityPub.SideEffects.handle(activity, meta)) + end + end + end +end From 1adafa096653c4538e4162a2dffba982ee6c6d8e Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 23 Oct 2019 12:18:05 +0200 Subject: [PATCH 009/188] Credo fixes. --- lib/pleroma/web/activity_pub/builder.ex | 4 ++-- lib/pleroma/web/activity_pub/object_validator.ex | 4 ++-- .../web/activity_pub/object_validators/like_validator.ex | 8 ++++++-- lib/pleroma/web/activity_pub/side_effects.ex | 4 ++-- test/web/activity_pub/object_validator_test.exs | 3 ++- 5 files changed, 14 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 1787f1510..429a510b8 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -5,10 +5,10 @@ defmodule Pleroma.Web.ActivityPub.Builder do This module encodes our addressing policies and general shape of our objects. """ + alias Pleroma.Object + alias Pleroma.User alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility - alias Pleroma.User - alias Pleroma.Object @spec like(User.t(), Object.t()) :: {:ok, map(), keyword()} def like(actor, object) do diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 33e67dbb9..27a8dd852 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -9,9 +9,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do the system. """ - alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator - alias Pleroma.User alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) 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 e6a5aaca8..5fa486653 100644 --- a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex @@ -1,11 +1,15 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator do use Ecto.Schema import Ecto.Changeset + alias Pleroma.Object + alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.Utils - alias Pleroma.User - alias Pleroma.Object @primary_key false diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 6d3e77a62..666a4e310 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -5,9 +5,9 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do liked object, a `Follow` activity will add the user to the follower collection, and so on. """ - alias Pleroma.Web.ActivityPub.Utils - alias Pleroma.Object alias Pleroma.Notification + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.Utils def handle(object, meta \\ []) diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 2292db6d7..3c5c3696e 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -1,10 +1,11 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do use Pleroma.DataCase - alias Pleroma.Web.CommonAPI alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.CommonAPI + import Pleroma.Factory describe "likes" do From 25077812bfc8a7a94c3fa953b2924003296470c2 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 23 Oct 2019 12:25:20 +0200 Subject: [PATCH 010/188] SideEffectsTest: Fix test. --- test/web/activity_pub/side_effects_test.exs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 9d99e05a0..b34e45a7f 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -4,11 +4,12 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do use Pleroma.DataCase + alias Pleroma.Object - alias Pleroma.Web.CommonAPI - alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.SideEffects + alias Pleroma.Web.CommonAPI import Pleroma.Factory @@ -18,7 +19,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do {:ok, post} = CommonAPI.post(user, %{"status" => "hey"}) {:ok, like_data, _meta} = Builder.like(user, post.object) - {:ok, like, _meta} = ActivityPub.persist(like_data, []) + {:ok, like, _meta} = ActivityPub.persist(like_data, [local: true]) %{like: like, user: user} end From 3d1b445cbf001f76af614441c241dcc299e76af7 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 Nov 2019 15:02:09 +0100 Subject: [PATCH 011/188] Object Validators: Extract common validations. --- .../web/activity_pub/object_validator.ex | 7 ++-- .../object_validators/common_validations.ex | 32 +++++++++++++++++++ .../object_validators/like_validator.ex | 26 +++------------ .../web/activity_pub/transmogrifier.ex | 7 ++-- test/web/activity_pub/side_effects_test.exs | 2 +- 5 files changed, 47 insertions(+), 27 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/object_validators/common_validations.ex diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 27a8dd852..539be1143 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -17,9 +17,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do def validate(object, meta) def validate(%{"type" => "Like"} = object, meta) do - with {_, %{valid?: true, changes: object}} <- - {:validate_object, LikeValidator.cast_and_validate(object)} do - object = stringify_keys(object) + with {_, {:ok, object}} <- + {:validate_object, + object |> LikeValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert)} do + object = stringify_keys(object |> Map.from_struct()) {:ok, object, meta} else e -> {:error, e} diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex new file mode 100644 index 000000000..db0e2072d --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -0,0 +1,32 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do + import Ecto.Changeset + + alias Pleroma.Object + alias Pleroma.User + + def validate_actor_presence(cng, field_name \\ :actor) do + cng + |> validate_change(field_name, fn field_name, actor -> + if User.get_cached_by_ap_id(actor) do + [] + else + [{field_name, "can't find user"}] + end + end) + end + + def validate_object_presence(cng, field_name \\ :object) do + cng + |> validate_change(field_name, fn field_name, actor -> + if Object.get_cached_by_ap_id(actor) do + [] + else + [{field_name, "can't find user"}] + end + end) + end +end 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 5fa486653..ccbc7d071 100644 --- a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex @@ -4,13 +4,13 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator do use Ecto.Schema - import Ecto.Changeset - alias Pleroma.Object - alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.Utils + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + @primary_key false embedded_schema do @@ -38,8 +38,8 @@ def validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["Like"]) |> validate_required([:id, :type, :object, :actor, :context, :to, :cc]) - |> validate_change(:actor, &actor_valid?/2) - |> validate_change(:object, &object_valid?/2) + |> validate_actor_presence() + |> validate_object_presence() |> validate_existing_like() end @@ -54,20 +54,4 @@ def validate_existing_like(%{changes: %{actor: actor, object: object}} = cng) do end def validate_existing_like(cng), do: cng - - def actor_valid?(field_name, actor) do - if User.get_cached_by_ap_id(actor) do - [] - else - [{field_name, "can't find user"}] - end - end - - def object_valid?(field_name, object) do - if Object.get_cached_by_ap_id(object) do - [] - else - [{field_name, "can't find object"}] - end - end end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 4dd884ce9..9a0c37e13 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -566,8 +566,11 @@ def handle_incoming( end def handle_incoming(%{"type" => "Like"} = data, _options) do - with {_, %{changes: cast_data}} <- {:casting_data, LikeValidator.cast_data(data)}, - cast_data <- ObjectValidator.stringify_keys(cast_data), + with {_, {:ok, cast_data_sym}} <- + {:casting_data, + data |> LikeValidator.cast_data() |> Ecto.Changeset.apply_action(:insert)}, + {_, cast_data} <- + {:stringify_keys, ObjectValidator.stringify_keys(cast_data_sym |> Map.from_struct())}, :ok <- ObjectValidator.fetch_actor_and_object(cast_data), {_, {:ok, cast_data}} <- {:maybe_add_context, maybe_add_context_from_object(cast_data)}, {_, {:ok, cast_data}} <- diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index b34e45a7f..ef91954ae 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -19,7 +19,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do {:ok, post} = CommonAPI.post(user, %{"status" => "hey"}) {:ok, like_data, _meta} = Builder.like(user, post.object) - {:ok, like, _meta} = ActivityPub.persist(like_data, [local: true]) + {:ok, like, _meta} = ActivityPub.persist(like_data, local: true) %{like: like, user: user} end From faced6236b9e2ce9675cf743068f16098b744562 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 Nov 2019 15:02:31 +0100 Subject: [PATCH 012/188] NoteValidator: Add very basic validator for Note objects. --- .../object_validators/note_validator.ex | 64 +++++++++++++++++++ .../object_validators/note_validator_test.exs | 35 ++++++++++ 2 files changed, 99 insertions(+) create mode 100644 lib/pleroma/web/activity_pub/object_validators/note_validator.ex create mode 100644 test/web/activity_pub/object_validators/note_validator_test.exs diff --git a/lib/pleroma/web/activity_pub/object_validators/note_validator.ex b/lib/pleroma/web/activity_pub/object_validators/note_validator.ex new file mode 100644 index 000000000..c660f30f0 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/note_validator.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.Web.ActivityPub.ObjectValidators.NoteValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + + import Ecto.Changeset + + @primary_key false + + embedded_schema do + field(:id, :string, primary_key: true) + field(:to, {:array, :string}, default: []) + field(:cc, {:array, :string}, default: []) + field(:bto, {:array, :string}, default: []) + field(:bcc, {:array, :string}, default: []) + # TODO: Write type + field(:tag, {:array, :map}, default: []) + field(:type, :string) + field(:content, :string) + field(:context, :string) + field(:actor, Types.ObjectID) + field(:attributedTo, Types.ObjectID) + field(:summary, :string) + # TODO: Write type + field(:published, :string) + # TODO: Write type + field(:emoji, :map, default: %{}) + field(:sensitive, :boolean, default: false) + # TODO: Write type + field(:attachment, {:array, :map}, default: []) + field(:replies_count, :integer, default: 0) + field(:like_count, :integer, default: 0) + field(:announcement_count, :integer, default: 0) + field(:inRepyTo, :string) + + field(:likes, {:array, :string}, default: []) + field(:announcements, {:array, :string}, default: []) + + # see if needed + field(:conversation, :string) + field(:context_id, :string) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> cast(data, __schema__(:fields)) + end + + def validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["Note"]) + |> validate_required([:id, :actor, :to, :cc, :type, :content, :context]) + end +end diff --git a/test/web/activity_pub/object_validators/note_validator_test.exs b/test/web/activity_pub/object_validators/note_validator_test.exs new file mode 100644 index 000000000..2bcd75e25 --- /dev/null +++ b/test/web/activity_pub/object_validators/note_validator_test.exs @@ -0,0 +1,35 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidatorTest do + use Pleroma.DataCase + + alias Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator + alias Pleroma.Web.ActivityPub.Utils + + import Pleroma.Factory + + describe "Notes" do + setup do + user = insert(:user) + + note = %{ + "id" => Utils.generate_activity_id(), + "type" => "Note", + "actor" => user.ap_id, + "to" => [user.follower_address], + "cc" => [], + "content" => "Hellow this is content.", + "context" => "xxx", + "summary" => "a post" + } + + %{user: user, note: note} + end + + test "a basic note validates", %{note: note} do + %{valid?: true} = NoteValidator.cast_and_validate(note) + end + end +end From 1993d7096d673d8a8151fedd7bcac909d584d13d Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 5 Dec 2019 12:33:06 +0100 Subject: [PATCH 013/188] Validators: Add a type for the datetime used in AP. --- .../object_validators/note_validator.ex | 3 +- .../object_validators/types/date_time.ex | 34 +++++++++++++++++++ .../types/date_time_test.exs | 32 +++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/object_validators/types/date_time.ex create mode 100644 test/web/activity_pub/object_validators/types/date_time_test.exs diff --git a/lib/pleroma/web/activity_pub/object_validators/note_validator.ex b/lib/pleroma/web/activity_pub/object_validators/note_validator.ex index c660f30f0..eea15ce1c 100644 --- a/lib/pleroma/web/activity_pub/object_validators/note_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/note_validator.ex @@ -25,8 +25,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator do field(:actor, Types.ObjectID) field(:attributedTo, Types.ObjectID) field(:summary, :string) - # TODO: Write type - field(:published, :string) + field(:published, Types.DateTime) # TODO: Write type field(:emoji, :map, default: %{}) field(:sensitive, :boolean, default: false) diff --git a/lib/pleroma/web/activity_pub/object_validators/types/date_time.ex b/lib/pleroma/web/activity_pub/object_validators/types/date_time.ex new file mode 100644 index 000000000..4f412fcde --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/types/date_time.ex @@ -0,0 +1,34 @@ +defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.DateTime do + @moduledoc """ + The AP standard defines the date fields in AP as xsd:DateTime. Elixir's + DateTime can't parse this, but it can parse the related iso8601. This + module punches the date until it looks like iso8601 and normalizes to + it. + + DateTimes without a timezone offset are treated as UTC. + + Reference: https://www.w3.org/TR/activitystreams-vocabulary/#dfn-published + """ + use Ecto.Type + + def type, do: :string + + def cast(datetime) when is_binary(datetime) do + with {:ok, datetime, _} <- DateTime.from_iso8601(datetime) do + {:ok, DateTime.to_iso8601(datetime)} + else + {:error, :missing_offset} -> cast("#{datetime}Z") + _e -> :error + end + end + + def cast(_), do: :error + + 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/date_time_test.exs b/test/web/activity_pub/object_validators/types/date_time_test.exs new file mode 100644 index 000000000..3e17a9497 --- /dev/null +++ b/test/web/activity_pub/object_validators/types/date_time_test.exs @@ -0,0 +1,32 @@ +defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.DateTimeTest do + alias Pleroma.Web.ActivityPub.ObjectValidators.Types.DateTime + use Pleroma.DataCase + + test "it validates an xsd:Datetime" do + valid_strings = [ + "2004-04-12T13:20:00", + "2004-04-12T13:20:15.5", + "2004-04-12T13:20:00-05:00", + "2004-04-12T13:20:00Z" + ] + + invalid_strings = [ + "2004-04-12T13:00", + "2004-04-1213:20:00", + "99-04-12T13:00", + "2004-04-12" + ] + + assert {:ok, "2004-04-01T12:00:00Z"} == DateTime.cast("2004-04-01T12:00:00Z") + + Enum.each(valid_strings, fn date_time -> + result = DateTime.cast(date_time) + assert {:ok, _} = result + end) + + Enum.each(invalid_strings, fn date_time -> + result = DateTime.cast(date_time) + assert :error == result + end) + end +end From d4bafabfd14887e61eb5bc1d877035dcfebbd33f Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 9 Dec 2019 10:39:14 +0100 Subject: [PATCH 014/188] Beginnings of the create validator --- .../object_validators/create_validator.ex | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 lib/pleroma/web/activity_pub/object_validators/create_validator.ex diff --git a/lib/pleroma/web/activity_pub/object_validators/create_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_validator.ex new file mode 100644 index 000000000..bd90f7250 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/create_validator.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.Web.ActivityPub.ObjectValidators.CreateNoteValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator + + import Ecto.Changeset + + @primary_key false + + embedded_schema do + field(:id, :string, primary_key: true) + field(:actor, Types.ObjectID) + field(:type, :string) + field(:to, {:array, :string}) + field(:cc, {:array, :string}) + field(:bto, {:array, :string}, default: []) + field(:bcc, {:array, :string}, default: []) + + embeds_one(:object, NoteValidator) + end + + def cast_data(data) do + %__MODULE__{} + |> cast(data, __schema__(:fields)) + end +end From 514c899275a32e6ef63305f9424c50344d41b12e Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 11 Feb 2020 10:12:57 +0300 Subject: [PATCH 015/188] adding gun adapter --- CHANGELOG.md | 1 + config/config.exs | 67 +- config/description.exs | 2 +- config/test.exs | 2 + docs/API/admin_api.md | 2 + docs/configuration/cheatsheet.md | 36 +- lib/mix/tasks/pleroma/benchmark.ex | 39 + lib/mix/tasks/pleroma/emoji.ex | 9 +- lib/pleroma/application.ex | 90 +- lib/pleroma/config/config_db.ex | 11 - lib/pleroma/config/transfer_task.ex | 43 +- lib/pleroma/gun/api.ex | 26 + lib/pleroma/gun/api/mock.ex | 151 +++ lib/pleroma/gun/conn.ex | 29 + lib/pleroma/gun/gun.ex | 45 + lib/pleroma/http/adapter.ex | 64 ++ lib/pleroma/http/adapter/gun.ex | 123 +++ lib/pleroma/http/adapter/hackney.ex | 41 + lib/pleroma/http/connection.ex | 113 ++- lib/pleroma/http/http.ex | 156 ++- lib/pleroma/http/request.ex | 23 + lib/pleroma/http/request_builder.ex | 107 +- lib/pleroma/object/fetcher.ex | 6 +- lib/pleroma/otp_version.ex | 63 ++ lib/pleroma/pool/connections.ex | 415 ++++++++ lib/pleroma/pool/pool.ex | 22 + lib/pleroma/pool/request.ex | 72 ++ lib/pleroma/pool/supervisor.ex | 36 + lib/pleroma/reverse_proxy/client.ex | 26 +- lib/pleroma/reverse_proxy/client/hackney.ex | 24 + lib/pleroma/reverse_proxy/client/tesla.ex | 87 ++ lib/pleroma/reverse_proxy/reverse_proxy.ex | 20 +- .../mrf/media_proxy_warming_policy.ex | 14 +- lib/pleroma/web/rel_me.ex | 18 +- lib/pleroma/web/rich_media/parser.ex | 18 +- lib/pleroma/web/web_finger/web_finger.ex | 2 +- mix.exs | 4 + mix.lock | 2 + test/activity/ir/topics_test.exs | 2 +- test/config/config_db_test.exs | 8 - test/fixtures/warnings/otp_version/21.1 | 1 + test/fixtures/warnings/otp_version/22.1 | 1 + test/fixtures/warnings/otp_version/22.4 | 1 + test/fixtures/warnings/otp_version/23.0 | 1 + test/fixtures/warnings/otp_version/error | 1 + test/fixtures/warnings/otp_version/undefined | 1 + test/gun/gun_test.exs | 33 + test/http/adapter/gun_test.exs | 266 +++++ test/http/adapter/hackney_test.exs | 54 + test/http/adapter_test.exs | 65 ++ test/http/connection_test.exs | 142 +++ test/http/request_builder_test.exs | 30 +- test/http_test.exs | 35 +- test/notification_test.exs | 7 + test/otp_version_test.exs | 58 ++ test/pool/connections_test.exs | 959 ++++++++++++++++++ test/reverse_proxy/client/tesla_test.exs | 93 ++ .../reverse_proxy_test.exs | 121 ++- test/support/http_request_mock.ex | 94 +- test/user_invite_token_test.exs | 4 - .../admin_api/admin_api_controller_test.exs | 9 +- test/web/common_api/common_api_utils_test.exs | 7 + test/web/push/impl_test.exs | 2 +- 63 files changed, 3615 insertions(+), 389 deletions(-) create mode 100644 lib/pleroma/gun/api.ex create mode 100644 lib/pleroma/gun/api/mock.ex create mode 100644 lib/pleroma/gun/conn.ex create mode 100644 lib/pleroma/gun/gun.ex create mode 100644 lib/pleroma/http/adapter.ex create mode 100644 lib/pleroma/http/adapter/gun.ex create mode 100644 lib/pleroma/http/adapter/hackney.ex create mode 100644 lib/pleroma/http/request.ex create mode 100644 lib/pleroma/otp_version.ex create mode 100644 lib/pleroma/pool/connections.ex create mode 100644 lib/pleroma/pool/pool.ex create mode 100644 lib/pleroma/pool/request.ex create mode 100644 lib/pleroma/pool/supervisor.ex create mode 100644 lib/pleroma/reverse_proxy/client/hackney.ex create mode 100644 lib/pleroma/reverse_proxy/client/tesla.ex create mode 100644 test/fixtures/warnings/otp_version/21.1 create mode 100644 test/fixtures/warnings/otp_version/22.1 create mode 100644 test/fixtures/warnings/otp_version/22.4 create mode 100644 test/fixtures/warnings/otp_version/23.0 create mode 100644 test/fixtures/warnings/otp_version/error create mode 100644 test/fixtures/warnings/otp_version/undefined create mode 100644 test/gun/gun_test.exs create mode 100644 test/http/adapter/gun_test.exs create mode 100644 test/http/adapter/hackney_test.exs create mode 100644 test/http/adapter_test.exs create mode 100644 test/http/connection_test.exs create mode 100644 test/otp_version_test.exs create mode 100644 test/pool/connections_test.exs create mode 100644 test/reverse_proxy/client/tesla_test.exs rename test/{ => reverse_proxy}/reverse_proxy_test.exs (79%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e838983b..48080503a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Support for custom Elixir modules (such as MRF policies) - User settings: Add _This account is a_ option. - OAuth: admin scopes support (relevant setting: `[:auth, :enforce_oauth_admin_scope_usage]`). +- New HTTP adapter [gun](https://github.com/ninenines/gun). Gun adapter requires OTP version older that 22.2, otherwise pleroma won’t start. For hackney OTP update is not required.
API Changes diff --git a/config/config.exs b/config/config.exs index ccc0c4e52..27091393b 100644 --- a/config/config.exs +++ b/config/config.exs @@ -58,20 +58,6 @@ config :pleroma, Pleroma.Captcha.Kocaptcha, endpoint: "https://captcha.kotobank.ch" -config :pleroma, :hackney_pools, - federation: [ - max_connections: 50, - timeout: 150_000 - ], - media: [ - max_connections: 50, - timeout: 150_000 - ], - upload: [ - max_connections: 25, - timeout: 300_000 - ] - # Upload configuration config :pleroma, Pleroma.Upload, uploader: Pleroma.Uploaders.Local, @@ -185,20 +171,12 @@ } config :tesla, adapter: Tesla.Adapter.Hackney - # Configures http settings, upstream proxy etc. config :pleroma, :http, proxy_url: nil, send_user_agent: true, user_agent: :default, - adapter: [ - ssl_options: [ - # Workaround for remote server certificate chain issues - partial_chain: &:hackney_connect.partial_chain/1, - # We don't support TLS v1.3 yet - versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"] - ] - ] + adapter: [] config :pleroma, :instance, name: "Pleroma", @@ -612,6 +590,49 @@ config :pleroma, configurable_from_database: false +config :pleroma, :connections_pool, + receive_connection_timeout: 250, + max_connections: 250, + retry: 5, + retry_timeout: 100, + await_up_timeout: 5_000 + +config :pleroma, :pools, + federation: [ + size: 50, + max_overflow: 10, + timeout: 150_000 + ], + media: [ + size: 50, + max_overflow: 10, + timeout: 150_000 + ], + upload: [ + size: 25, + max_overflow: 5, + timeout: 300_000 + ], + default: [ + size: 10, + max_overflow: 2, + timeout: 10_000 + ] + +config :pleroma, :hackney_pools, + federation: [ + max_connections: 50, + timeout: 150_000 + ], + media: [ + max_connections: 50, + timeout: 150_000 + ], + upload: [ + max_connections: 25, + timeout: 300_000 + ] + # 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/config/description.exs b/config/description.exs index efea7c137..d5322fa33 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2728,7 +2728,7 @@ key: :adapter, type: :module, description: "Tesla adapter", - suggestions: [Tesla.Adapter.Hackney] + suggestions: [Tesla.Adapter.Hackney, Tesla.Adapter.Gun] } ] }, diff --git a/config/test.exs b/config/test.exs index 078c46205..83783cf8f 100644 --- a/config/test.exs +++ b/config/test.exs @@ -94,6 +94,8 @@ config :pleroma, :modules, runtime_dir: "test/fixtures/modules" +config :pleroma, Pleroma.Gun.API, Pleroma.Gun.API.Mock + if File.exists?("./config/test.secret.exs") do import_config "test.secret.exs" else diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index fb6dfcb08..cd8123c5d 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -731,6 +731,8 @@ Some modifications are necessary to save the config settings correctly: Most of the settings will be applied in `runtime`, this means that you don't need to restart the instance. But some settings are applied in `compile time` and require a reboot of the instance, such as: - all settings inside these keys: - `:hackney_pools` + - `:connections_pool` + - `:pools` - `:chat` - partially settings inside these keys: - `:seconds_valid` in `Pleroma.Captcha` diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 2bd935983..1c67eca35 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -368,8 +368,7 @@ Available caches: * `proxy_url`: an upstream proxy to fetch posts and/or media with, (default: `nil`) * `send_user_agent`: should we include a user agent with HTTP requests? (default: `true`) * `user_agent`: what user agent should we use? (default: `:default`), must be string or `:default` -* `adapter`: array of hackney options - +* `adapter`: array of adapter options ### :hackney_pools @@ -388,6 +387,39 @@ For each pool, the options are: * `timeout` - retention duration for connections +### :connections_pool + +*For `gun` adapter* + +Advanced settings for connections pool. Pool with opened connections. These connections can be reused in worker pools. + +* `:receive_connection_timeout` - timeout to receive connection from pool. Default: 250ms. +* `:max_connections` - maximum number of connections in the pool. Default: 250 connections. +* `:retry` - number of retries, while `gun` will try to reconnect if connections goes down. Default: 5. +* `:retry_timeout` - timeout while `gun` will try to reconnect. Default: 100ms. +* `:await_up_timeout` - timeout while `gun` will wait until connection is up. Default: 5000ms. + +### :pools + +*For `gun` adapter* + +Advanced settings for workers pools. + +There's four pools used: + +* `:federation` for the federation jobs. + You may want this pool max_connections to be at least equal to the number of federator jobs + retry queue jobs. +* `:media` for rich media, media proxy +* `:upload` for uploaded media (if using a remote uploader and `proxy_remote: true`) +* `:default` for other requests + +For each pool, the options are: + +* `:size` - how much workers the pool can hold +* `:timeout` - timeout while `gun` will wait for response +* `:max_overflow` - additional workers if pool is under load + + ## Captcha ### Pleroma.Captcha diff --git a/lib/mix/tasks/pleroma/benchmark.ex b/lib/mix/tasks/pleroma/benchmark.ex index 84dccf7f3..01e079136 100644 --- a/lib/mix/tasks/pleroma/benchmark.ex +++ b/lib/mix/tasks/pleroma/benchmark.ex @@ -74,4 +74,43 @@ def run(["render_timeline", nickname | _] = args) do inputs: inputs ) end + + def run(["adapters"]) do + start_pleroma() + + :ok = + Pleroma.Pool.Connections.open_conn( + "https://httpbin.org/stream-bytes/1500", + :gun_connections + ) + + Process.sleep(1_500) + + Benchee.run( + %{ + "Without conn and without pool" => fn -> + {:ok, %Tesla.Env{}} = + Pleroma.HTTP.get("https://httpbin.org/stream-bytes/1500", [], + adapter: [pool: :no_pool, receive_conn: false] + ) + end, + "Without conn and with pool" => fn -> + {:ok, %Tesla.Env{}} = + Pleroma.HTTP.get("https://httpbin.org/stream-bytes/1500", [], + adapter: [receive_conn: false] + ) + end, + "With reused conn and without pool" => fn -> + {:ok, %Tesla.Env{}} = + Pleroma.HTTP.get("https://httpbin.org/stream-bytes/1500", [], + adapter: [pool: :no_pool] + ) + end, + "With reused conn and with pool" => fn -> + {:ok, %Tesla.Env{}} = Pleroma.HTTP.get("https://httpbin.org/stream-bytes/1500") + end + }, + parallel: 10 + ) + end end diff --git a/lib/mix/tasks/pleroma/emoji.ex b/lib/mix/tasks/pleroma/emoji.ex index 24d999707..b4e8d3a0b 100644 --- a/lib/mix/tasks/pleroma/emoji.ex +++ b/lib/mix/tasks/pleroma/emoji.ex @@ -4,13 +4,13 @@ defmodule Mix.Tasks.Pleroma.Emoji do use Mix.Task + import Mix.Pleroma @shortdoc "Manages emoji packs" @moduledoc File.read!("docs/administration/CLI_tasks/emoji.md") def run(["ls-packs" | args]) do - Mix.Pleroma.start_pleroma() - Application.ensure_all_started(:hackney) + start_pleroma() {options, [], []} = parse_global_opts(args) @@ -36,8 +36,7 @@ def run(["ls-packs" | args]) do end def run(["get-packs" | args]) do - Mix.Pleroma.start_pleroma() - Application.ensure_all_started(:hackney) + start_pleroma() {options, pack_names, []} = parse_global_opts(args) @@ -135,7 +134,7 @@ def run(["get-packs" | args]) do end def run(["gen-pack", src]) do - Application.ensure_all_started(:hackney) + start_pleroma() proposed_name = Path.basename(src) |> Path.rootname() name = String.trim(IO.gets("Pack name [#{proposed_name}]: ")) diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 27758cf94..df6d3a98d 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -3,8 +3,12 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Application do - import Cachex.Spec use Application + + import Cachex.Spec + + alias Pleroma.Config + require Logger @name Mix.Project.config()[:name] @@ -18,9 +22,9 @@ def named_version, do: @name <> " " <> @version def repository, do: @repository def user_agent do - case Pleroma.Config.get([:http, :user_agent], :default) do + case Config.get([:http, :user_agent], :default) do :default -> - info = "#{Pleroma.Web.base_url()} <#{Pleroma.Config.get([:instance, :email], "")}>" + info = "#{Pleroma.Web.base_url()} <#{Config.get([:instance, :email], "")}>" named_version() <> "; " <> info custom -> @@ -32,7 +36,7 @@ def user_agent do # for more information on OTP Applications def start(_type, _args) do Pleroma.HTML.compile_scrubbers() - Pleroma.Config.DeprecationWarnings.warn() + Config.DeprecationWarnings.warn() Pleroma.Plugs.HTTPSecurityPlug.warn_if_disabled() Pleroma.Repo.check_migrations_applied!() setup_instrumenters() @@ -42,17 +46,17 @@ def start(_type, _args) do children = [ Pleroma.Repo, - Pleroma.Config.TransferTask, + Config.TransferTask, Pleroma.Emoji, Pleroma.Captcha, Pleroma.Plugs.RateLimiter.Supervisor ] ++ cachex_children() ++ - hackney_pool_children() ++ + http_pools_children(Config.get(:env)) ++ [ Pleroma.Stats, Pleroma.JobQueueMonitor, - {Oban, Pleroma.Config.get(Oban)} + {Oban, Config.get(Oban)} ] ++ task_children(@env) ++ streamer_child(@env) ++ @@ -62,6 +66,18 @@ def start(_type, _args) do Pleroma.Gopher.Server ] + case Pleroma.OTPVersion.check_version() do + :ok -> :ok + {:error, version} -> raise " + !!!OTP VERSION WARNING!!! + You are using gun adapter with OTP version #{version}, which doesn't support correct handling of unordered certificates chains. + " + :undefined -> raise " + !!!OTP VERSION WARNING!!! + To support correct handling of unordered certificates chains - OTP version must be > 22.2. + " + end + # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: Pleroma.Supervisor] @@ -69,7 +85,7 @@ def start(_type, _args) do end def load_custom_modules do - dir = Pleroma.Config.get([:modules, :runtime_dir]) + dir = Config.get([:modules, :runtime_dir]) if dir && File.exists?(dir) do dir @@ -110,20 +126,6 @@ defp setup_instrumenters do Pleroma.Web.Endpoint.Instrumenter.setup() end - def enabled_hackney_pools do - [:media] ++ - if Application.get_env(:tesla, :adapter) == Tesla.Adapter.Hackney do - [:federation] - else - [] - end ++ - if Pleroma.Config.get([Pleroma.Upload, :proxy_remote]) do - [:upload] - else - [] - end - end - defp cachex_children do [ build_cachex("used_captcha", ttl_interval: seconds_valid_interval()), @@ -145,7 +147,7 @@ defp idempotency_expiration, do: expiration(default: :timer.seconds(6 * 60 * 60), interval: :timer.seconds(60)) defp seconds_valid_interval, - do: :timer.seconds(Pleroma.Config.get!([Pleroma.Captcha, :seconds_valid])) + do: :timer.seconds(Config.get!([Pleroma.Captcha, :seconds_valid])) defp build_cachex(type, opts), do: %{ @@ -154,7 +156,7 @@ defp build_cachex(type, opts), type: :worker } - defp chat_enabled?, do: Pleroma.Config.get([:chat, :enabled]) + defp chat_enabled?, do: Config.get([:chat, :enabled]) defp streamer_child(:test), do: [] @@ -168,13 +170,6 @@ defp chat_child(_env, true) do defp chat_child(_, _), do: [] - defp hackney_pool_children do - for pool <- enabled_hackney_pools() do - options = Pleroma.Config.get([:hackney_pools, pool]) - :hackney_pool.child_spec(pool, options) - end - end - defp task_children(:test) do [ %{ @@ -199,4 +194,37 @@ defp task_children(_) do } ] end + + # start hackney and gun pools in tests + defp http_pools_children(:test) do + hackney_options = Config.get([:hackney_pools, :federation]) + hackney_pool = :hackney_pool.child_spec(:federation, hackney_options) + [hackney_pool, Pleroma.Pool.Supervisor] + end + + defp http_pools_children(_) do + :tesla + |> Application.get_env(:adapter) + |> http_pools() + end + + defp http_pools(Tesla.Adapter.Hackney) do + pools = [:federation, :media] + + pools = + if Config.get([Pleroma.Upload, :proxy_remote]) do + [:upload | pools] + else + pools + end + + for pool <- pools do + options = Config.get([:hackney_pools, pool]) + :hackney_pool.child_spec(pool, options) + end + end + + defp http_pools(Tesla.Adapter.Gun), do: [Pleroma.Pool.Supervisor] + + defp http_pools(_), do: [] end diff --git a/lib/pleroma/config/config_db.ex b/lib/pleroma/config/config_db.ex index 119251bee..bdacefa97 100644 --- a/lib/pleroma/config/config_db.ex +++ b/lib/pleroma/config/config_db.ex @@ -278,8 +278,6 @@ defp do_convert({:proxy_url, {type, host, port}}) do } end - defp do_convert({:partial_chain, entity}), do: %{"tuple" => [":partial_chain", inspect(entity)]} - defp do_convert(entity) when is_tuple(entity) do value = entity @@ -323,15 +321,6 @@ defp do_transform(%{"tuple" => [":proxy_url", %{"tuple" => [type, host, port]}]} {:proxy_url, {do_transform_string(type), parse_host(host), port}} end - defp do_transform(%{"tuple" => [":partial_chain", entity]}) do - {partial_chain, []} = - entity - |> String.replace(~r/[^\w|^{:,[|^,|^[|^\]^}|^\/|^\.|^"]^\s/, "") - |> Code.eval_string() - - {:partial_chain, partial_chain} - end - defp do_transform(%{"tuple" => entity}) do Enum.reduce(entity, {}, fn val, acc -> Tuple.append(acc, do_transform(val)) end) end diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex index 6c5ba1f95..251074aaa 100644 --- a/lib/pleroma/config/transfer_task.ex +++ b/lib/pleroma/config/transfer_task.ex @@ -18,7 +18,10 @@ defmodule Pleroma.Config.TransferTask do {:pleroma, Oban}, {:pleroma, :rate_limit}, {:pleroma, :markup}, - {:plerome, :streamer} + {:pleroma, :streamer}, + {:pleroma, :pools}, + {:pleroma, :connections_pool}, + {:tesla, :adapter} ] @reboot_time_subkeys [ @@ -74,6 +77,28 @@ def load_and_update_env(deleted \\ [], restart_pleroma? \\ true) do end end + defp group_for_restart(:logger, key, _, merged_value) do + # change logger configuration in runtime, without restart + if Keyword.keyword?(merged_value) and + key not in [:compile_time_application, :backends, :compile_time_purge_matching] do + Logger.configure_backend(key, merged_value) + else + Logger.configure([{key, merged_value}]) + end + + nil + end + + defp group_for_restart(:tesla, _, _, _), do: :pleroma + + defp group_for_restart(group, _, _, _) when group != :pleroma, do: group + + defp group_for_restart(group, key, value, _) do + if pleroma_need_restart?(group, key, value) do + group + end + end + defp merge_and_update(setting) do try do key = ConfigDB.from_string(setting.key) @@ -95,21 +120,7 @@ defp merge_and_update(setting) do :ok = update_env(group, key, merged_value) - if group != :logger do - if group != :pleroma or pleroma_need_restart?(group, key, value) do - group - end - else - # change logger configuration in runtime, without restart - if Keyword.keyword?(merged_value) and - key not in [:compile_time_application, :backends, :compile_time_purge_matching] do - Logger.configure_backend(key, merged_value) - else - Logger.configure([{key, merged_value}]) - end - - nil - end + group_for_restart(group, key, value, merged_value) rescue error -> error_msg = diff --git a/lib/pleroma/gun/api.ex b/lib/pleroma/gun/api.ex new file mode 100644 index 000000000..a0c3c5415 --- /dev/null +++ b/lib/pleroma/gun/api.ex @@ -0,0 +1,26 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Gun.API do + @callback open(charlist(), pos_integer(), map()) :: {:ok, pid()} + @callback info(pid()) :: map() + @callback close(pid()) :: :ok + @callback await_up(pid) :: {:ok, atom()} | {:error, atom()} + @callback connect(pid(), map()) :: reference() + @callback await(pid(), reference()) :: {:response, :fin, 200, []} + + def open(host, port, opts), do: api().open(host, port, opts) + + def info(pid), do: api().info(pid) + + def close(pid), do: api().close(pid) + + def await_up(pid), do: api().await_up(pid) + + def connect(pid, opts), do: api().connect(pid, opts) + + def await(pid, ref), do: api().await(pid, ref) + + defp api, do: Pleroma.Config.get([Pleroma.Gun.API], Pleroma.Gun) +end diff --git a/lib/pleroma/gun/api/mock.ex b/lib/pleroma/gun/api/mock.ex new file mode 100644 index 000000000..0134b016e --- /dev/null +++ b/lib/pleroma/gun/api/mock.ex @@ -0,0 +1,151 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Gun.API.Mock do + @behaviour Pleroma.Gun.API + + alias Pleroma.Gun.API + + @impl API + def open('some-domain.com', 443, _) do + {:ok, conn_pid} = Task.start_link(fn -> Process.sleep(1_000) end) + + Registry.register(API.Mock, conn_pid, %{ + origin_scheme: "https", + origin_host: 'some-domain.com', + origin_port: 443 + }) + + {:ok, conn_pid} + end + + @impl API + def open(ip, port, _) + when ip in [{10_755, 10_368, 61_708, 131, 64_206, 45_068, 0, 9_694}, {127, 0, 0, 1}] and + port in [80, 443] do + {:ok, conn_pid} = Task.start_link(fn -> Process.sleep(1_000) end) + + scheme = if port == 443, do: "https", else: "http" + + Registry.register(API.Mock, conn_pid, %{ + origin_scheme: scheme, + origin_host: ip, + origin_port: port + }) + + {:ok, conn_pid} + end + + @impl API + def open('localhost', 1234, %{ + protocols: [:socks], + proxy: {:socks5, 'localhost', 1234}, + socks_opts: %{host: 'proxy-socks.com', port: 80, version: 5} + }) do + {:ok, conn_pid} = Task.start_link(fn -> Process.sleep(1_000) end) + + Registry.register(API.Mock, conn_pid, %{ + origin_scheme: "http", + origin_host: 'proxy-socks.com', + origin_port: 80 + }) + + {:ok, conn_pid} + end + + @impl API + def open('localhost', 1234, %{ + protocols: [:socks], + proxy: {:socks4, 'localhost', 1234}, + socks_opts: %{ + host: 'proxy-socks.com', + port: 443, + protocols: [:http2], + tls_opts: [], + transport: :tls, + version: 4 + } + }) do + {:ok, conn_pid} = Task.start_link(fn -> Process.sleep(1_000) end) + + Registry.register(API.Mock, conn_pid, %{ + origin_scheme: "https", + origin_host: 'proxy-socks.com', + origin_port: 443 + }) + + {:ok, conn_pid} + end + + @impl API + def open('gun-not-up.com', 80, _opts), do: {:error, :timeout} + + @impl API + def open('example.com', port, _) when port in [443, 115] do + {:ok, conn_pid} = Task.start_link(fn -> Process.sleep(1_000) end) + + Registry.register(API.Mock, conn_pid, %{ + origin_scheme: "https", + origin_host: 'example.com', + origin_port: 443 + }) + + {:ok, conn_pid} + end + + @impl API + def open(domain, 80, _) do + {:ok, conn_pid} = Task.start_link(fn -> Process.sleep(1_000) end) + + Registry.register(API.Mock, conn_pid, %{ + origin_scheme: "http", + origin_host: domain, + origin_port: 80 + }) + + {:ok, conn_pid} + end + + @impl API + def open({127, 0, 0, 1}, 8123, _) do + Task.start_link(fn -> Process.sleep(1_000) end) + end + + @impl API + def open('localhost', 9050, _) do + Task.start_link(fn -> Process.sleep(1_000) end) + end + + @impl API + def await_up(_pid), do: {:ok, :http} + + @impl API + def connect(pid, %{host: _, port: 80}) do + ref = make_ref() + Registry.register(API.Mock, ref, pid) + ref + end + + @impl API + def connect(pid, %{host: _, port: 443, protocols: [:http2], transport: :tls}) do + ref = make_ref() + Registry.register(API.Mock, ref, pid) + ref + end + + @impl API + def await(pid, ref) do + [{_, ^pid}] = Registry.lookup(API.Mock, ref) + {:response, :fin, 200, []} + end + + @impl API + def info(pid) do + [{_, info}] = Registry.lookup(API.Mock, pid) + info + end + + @impl API + def close(_pid), do: :ok +end diff --git a/lib/pleroma/gun/conn.ex b/lib/pleroma/gun/conn.ex new file mode 100644 index 000000000..2474829d6 --- /dev/null +++ b/lib/pleroma/gun/conn.ex @@ -0,0 +1,29 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Gun.Conn do + @moduledoc """ + Struct for gun connection data + """ + @type gun_state :: :up | :down + @type conn_state :: :active | :idle + + @type t :: %__MODULE__{ + conn: pid(), + gun_state: gun_state(), + conn_state: conn_state(), + used_by: [pid()], + last_reference: pos_integer(), + crf: float(), + retries: pos_integer() + } + + defstruct conn: nil, + gun_state: :open, + conn_state: :init, + used_by: [], + last_reference: 0, + crf: 1, + retries: 0 +end diff --git a/lib/pleroma/gun/gun.ex b/lib/pleroma/gun/gun.ex new file mode 100644 index 000000000..4a1bbc95f --- /dev/null +++ b/lib/pleroma/gun/gun.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.Gun do + @behaviour Pleroma.Gun.API + + alias Pleroma.Gun.API + + @gun_keys [ + :connect_timeout, + :http_opts, + :http2_opts, + :protocols, + :retry, + :retry_timeout, + :trace, + :transport, + :tls_opts, + :tcp_opts, + :socks_opts, + :ws_opts + ] + + @impl API + def open(host, port, opts \\ %{}), do: :gun.open(host, port, Map.take(opts, @gun_keys)) + + @impl API + defdelegate info(pid), to: :gun + + @impl API + defdelegate close(pid), to: :gun + + @impl API + defdelegate await_up(pid), to: :gun + + @impl API + defdelegate connect(pid, opts), to: :gun + + @impl API + defdelegate await(pid, ref), to: :gun + + @spec flush(pid() | reference()) :: :ok + defdelegate flush(pid), to: :gun +end diff --git a/lib/pleroma/http/adapter.ex b/lib/pleroma/http/adapter.ex new file mode 100644 index 000000000..6166a3eb4 --- /dev/null +++ b/lib/pleroma/http/adapter.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.HTTP.Adapter do + alias Pleroma.HTTP.Connection + + @type proxy :: + {Connection.host(), pos_integer()} + | {Connection.proxy_type(), pos_integer()} + @type host_type :: :domain | :ip + + @callback options(keyword(), URI.t()) :: keyword() + @callback after_request(keyword()) :: :ok + + @spec options(keyword(), URI.t()) :: keyword() + def options(opts, _uri) do + proxy = Pleroma.Config.get([:http, :proxy_url], nil) + maybe_add_proxy(opts, format_proxy(proxy)) + end + + @spec maybe_get_conn(URI.t(), keyword()) :: keyword() + def maybe_get_conn(_uri, opts), do: opts + + @spec after_request(keyword()) :: :ok + def after_request(_opts), do: :ok + + @spec format_proxy(String.t() | tuple() | nil) :: proxy() | nil + def format_proxy(nil), do: nil + + def format_proxy(proxy_url) do + with {:ok, host, port} <- Connection.parse_proxy(proxy_url) do + {host, port} + else + {:ok, type, host, port} -> {type, host, port} + _ -> nil + end + end + + @spec maybe_add_proxy(keyword(), proxy() | nil) :: keyword() + def maybe_add_proxy(opts, nil), do: opts + def maybe_add_proxy(opts, proxy), do: Keyword.put_new(opts, :proxy, proxy) + + @spec domain_or_fallback(String.t()) :: charlist() + def domain_or_fallback(host) do + case domain_or_ip(host) do + {:domain, domain} -> domain + {:ip, _ip} -> to_charlist(host) + end + end + + @spec domain_or_ip(String.t()) :: {host_type(), Connection.host()} + def domain_or_ip(host) do + charlist = to_charlist(host) + + case :inet.parse_address(charlist) do + {:error, :einval} -> + {:domain, :idna.encode(charlist)} + + {:ok, ip} when is_tuple(ip) and tuple_size(ip) in [4, 8] -> + {:ip, ip} + end + end +end diff --git a/lib/pleroma/http/adapter/gun.ex b/lib/pleroma/http/adapter/gun.ex new file mode 100644 index 000000000..f25afeda7 --- /dev/null +++ b/lib/pleroma/http/adapter/gun.ex @@ -0,0 +1,123 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.Adapter.Gun do + @behaviour Pleroma.HTTP.Adapter + + alias Pleroma.HTTP.Adapter + + require Logger + + alias Pleroma.Pool.Connections + + @defaults [ + connect_timeout: 20_000, + domain_lookup_timeout: 5_000, + tls_handshake_timeout: 5_000, + retry_timeout: 100, + await_up_timeout: 5_000 + ] + + @spec options(keyword(), URI.t()) :: keyword() + def options(connection_opts \\ [], %URI{} = uri) do + proxy = Pleroma.Config.get([:http, :proxy_url], nil) + + @defaults + |> Keyword.merge(Pleroma.Config.get([:http, :adapter], [])) + |> add_original(uri) + |> add_scheme_opts(uri) + |> Adapter.maybe_add_proxy(Adapter.format_proxy(proxy)) + |> maybe_get_conn(uri, connection_opts) + end + + @spec after_request(keyword()) :: :ok + def after_request(opts) do + with conn when not is_nil(conn) <- opts[:conn], + body_as when body_as != :chunks <- opts[:body_as] do + Connections.checkout(conn, self(), :gun_connections) + end + + :ok + end + + defp add_original(opts, %URI{host: host, port: port}) do + formatted_host = Adapter.domain_or_fallback(host) + + Keyword.put(opts, :original, "#{formatted_host}:#{port}") + end + + defp add_scheme_opts(opts, %URI{scheme: "http"}), do: opts + + defp add_scheme_opts(opts, %URI{scheme: "https", host: host, port: port}) do + adapter_opts = [ + certificates_verification: true, + tls_opts: [ + verify: :verify_peer, + cacertfile: CAStore.file_path(), + depth: 20, + reuse_sessions: false, + verify_fun: + {&:ssl_verify_hostname.verify_fun/3, [check_hostname: Adapter.domain_or_fallback(host)]} + ] + ] + + adapter_opts = + if port != 443 do + Keyword.put(adapter_opts, :transport, :tls) + else + adapter_opts + end + + Keyword.merge(opts, adapter_opts) + end + + defp maybe_get_conn(adapter_opts, uri, connection_opts) do + {receive_conn?, opts} = + adapter_opts + |> Keyword.merge(connection_opts) + |> Keyword.pop(:receive_conn, true) + + if Connections.alive?(:gun_connections) and receive_conn? do + try_to_get_conn(uri, opts) + else + opts + end + end + + defp try_to_get_conn(uri, opts) do + try do + case Connections.checkin(uri, :gun_connections) do + nil -> + Logger.info( + "Gun connections pool checkin was not succesfull. Trying to open conn for next request." + ) + + :ok = Connections.open_conn(uri, :gun_connections, opts) + opts + + conn when is_pid(conn) -> + Logger.debug("received conn #{inspect(conn)} #{Connections.compose_uri(uri)}") + + opts + |> Keyword.put(:conn, conn) + |> Keyword.put(:close_conn, false) + end + rescue + error -> + Logger.warn("Gun connections pool checkin caused error #{inspect(error)}") + opts + catch + :exit, {:timeout, _} -> + Logger.info( + "Gun connections pool checkin with timeout error #{Connections.compose_uri(uri)}" + ) + + opts + + :exit, error -> + Logger.warn("Gun pool checkin exited with error #{inspect(error)}") + opts + end + end +end diff --git a/lib/pleroma/http/adapter/hackney.ex b/lib/pleroma/http/adapter/hackney.ex new file mode 100644 index 000000000..00db30083 --- /dev/null +++ b/lib/pleroma/http/adapter/hackney.ex @@ -0,0 +1,41 @@ +defmodule Pleroma.HTTP.Adapter.Hackney do + @behaviour Pleroma.HTTP.Adapter + + @defaults [ + connect_timeout: 10_000, + recv_timeout: 20_000, + follow_redirect: true, + force_redirect: true, + pool: :federation + ] + + @spec options(keyword(), URI.t()) :: keyword() + def options(connection_opts \\ [], %URI{} = uri) do + proxy = Pleroma.Config.get([:http, :proxy_url], nil) + + @defaults + |> Keyword.merge(Pleroma.Config.get([:http, :adapter], [])) + |> Keyword.merge(connection_opts) + |> add_scheme_opts(uri) + |> Pleroma.HTTP.Adapter.maybe_add_proxy(proxy) + end + + defp add_scheme_opts(opts, %URI{scheme: "http"}), do: opts + + defp add_scheme_opts(opts, %URI{scheme: "https", host: host}) do + ssl_opts = [ + ssl_options: [ + # Workaround for remote server certificate chain issues + partial_chain: &:hackney_connect.partial_chain/1, + + # We don't support TLS v1.3 yet + versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"], + server_name_indication: to_charlist(host) + ] + ] + + Keyword.merge(opts, ssl_opts) + end + + def after_request(_), do: :ok +end diff --git a/lib/pleroma/http/connection.ex b/lib/pleroma/http/connection.ex index 7e2c6f5e8..85918341a 100644 --- a/lib/pleroma/http/connection.ex +++ b/lib/pleroma/http/connection.ex @@ -4,40 +4,99 @@ defmodule Pleroma.HTTP.Connection do @moduledoc """ - Connection for http-requests. + Configure Tesla.Client with default and customized adapter options. """ + @type ip_address :: ipv4_address() | ipv6_address() + @type ipv4_address :: {0..255, 0..255, 0..255, 0..255} + @type ipv6_address :: + {0..65_535, 0..65_535, 0..65_535, 0..65_535, 0..65_535, 0..65_535, 0..65_535, 0..65_535} + @type proxy_type() :: :socks4 | :socks5 + @type host() :: charlist() | ip_address() - @hackney_options [ - connect_timeout: 10_000, - recv_timeout: 20_000, - follow_redirect: true, - force_redirect: true, - pool: :federation - ] - @adapter Application.get_env(:tesla, :adapter) + @defaults [pool: :federation] + + require Logger + + alias Pleroma.Config + alias Pleroma.HTTP.Adapter @doc """ - Configure a client connection - - # Returns - - Tesla.Env.client + Merge default connection & adapter options with received ones. """ - @spec new(Keyword.t()) :: Tesla.Env.client() - def new(opts \\ []) do - Tesla.client([], {@adapter, hackney_options(opts)}) + + @spec options(URI.t(), keyword()) :: keyword() + def options(%URI{} = uri, opts \\ []) do + @defaults + |> pool_timeout() + |> Keyword.merge(opts) + |> adapter().options(uri) end - # fetch Hackney options - # - def hackney_options(opts) do - options = Keyword.get(opts, :adapter, []) - adapter_options = Pleroma.Config.get([:http, :adapter], []) - proxy_url = Pleroma.Config.get([:http, :proxy_url], nil) + defp pool_timeout(opts) do + timeout = + Config.get([:pools, opts[:pool], :timeout]) || Config.get([:pools, :default, :timeout]) - @hackney_options - |> Keyword.merge(adapter_options) - |> Keyword.merge(options) - |> Keyword.merge(proxy: proxy_url) + Keyword.merge(opts, timeout: timeout) + end + + @spec after_request(keyword()) :: :ok + def after_request(opts), do: adapter().after_request(opts) + + defp adapter do + case Application.get_env(:tesla, :adapter) do + Tesla.Adapter.Gun -> Adapter.Gun + Tesla.Adapter.Hackney -> Adapter.Hackney + _ -> Adapter + end + end + + @spec parse_proxy(String.t() | tuple() | nil) :: + {:ok, host(), pos_integer()} + | {:ok, proxy_type(), host(), pos_integer()} + | {:error, atom()} + | nil + + def parse_proxy(nil), do: nil + + def parse_proxy(proxy) when is_binary(proxy) do + with [host, port] <- String.split(proxy, ":"), + {port, ""} <- Integer.parse(port) do + {:ok, parse_host(host), port} + else + {_, _} -> + Logger.warn("parsing port in proxy fail #{inspect(proxy)}") + {:error, :error_parsing_port_in_proxy} + + :error -> + Logger.warn("parsing port in proxy fail #{inspect(proxy)}") + {:error, :error_parsing_port_in_proxy} + + _ -> + Logger.warn("parsing proxy fail #{inspect(proxy)}") + {:error, :error_parsing_proxy} + end + end + + def parse_proxy(proxy) when is_tuple(proxy) do + with {type, host, port} <- proxy do + {:ok, type, parse_host(host), port} + else + _ -> + Logger.warn("parsing proxy fail #{inspect(proxy)}") + {:error, :error_parsing_proxy} + end + end + + @spec parse_host(String.t() | atom() | charlist()) :: charlist() | ip_address() + def parse_host(host) when is_list(host), do: host + def parse_host(host) when is_atom(host), do: to_charlist(host) + + def parse_host(host) when is_binary(host) do + host = to_charlist(host) + + case :inet.parse_address(host) do + {:error, :einval} -> host + {:ok, ip} -> ip + end end end diff --git a/lib/pleroma/http/http.ex b/lib/pleroma/http/http.ex index dec24458a..ad47dc936 100644 --- a/lib/pleroma/http/http.ex +++ b/lib/pleroma/http/http.ex @@ -4,21 +4,47 @@ defmodule Pleroma.HTTP do @moduledoc """ - + Wrapper for `Tesla.request/2`. """ alias Pleroma.HTTP.Connection + alias Pleroma.HTTP.Request alias Pleroma.HTTP.RequestBuilder, as: Builder + alias Tesla.Client + alias Tesla.Env + + require Logger @type t :: __MODULE__ @doc """ - Builds and perform http request. + Performs GET request. + + See `Pleroma.HTTP.request/5` + """ + @spec get(Request.url() | nil, Request.headers(), keyword()) :: + nil | {:ok, Env.t()} | {:error, any()} + def get(url, headers \\ [], options \\ []) + def get(nil, _, _), do: nil + def get(url, headers, options), do: request(:get, url, "", headers, options) + + @doc """ + Performs POST request. + + See `Pleroma.HTTP.request/5` + """ + @spec post(Request.url(), String.t(), Request.headers(), keyword()) :: + {:ok, Env.t()} | {:error, any()} + def post(url, body, headers \\ [], options \\ []), + do: request(:post, url, body, headers, options) + + @doc """ + Builds and performs http request. # Arguments: `method` - :get, :post, :put, :delete - `url` - `body` + `url` - full url + `body` - request body `headers` - a keyworld list of headers, e.g. `[{"content-type", "text/plain"}]` `options` - custom, per-request middleware or adapter options @@ -26,23 +52,78 @@ defmodule Pleroma.HTTP do `{:ok, %Tesla.Env{}}` or `{:error, error}` """ - def request(method, url, body \\ "", headers \\ [], options \\ []) do + @spec request(atom(), Request.url(), String.t(), Request.headers(), keyword()) :: + {:ok, Env.t()} | {:error, any()} + def request(method, url, body, headers, options) when is_binary(url) do + with uri <- URI.parse(url), + received_adapter_opts <- Keyword.get(options, :adapter, []), + adapter_opts <- Connection.options(uri, received_adapter_opts), + options <- put_in(options[:adapter], adapter_opts), + params <- Keyword.get(options, :params, []), + request <- build_request(method, headers, options, url, body, params), + client <- Tesla.client([Tesla.Middleware.FollowRedirects], tesla_adapter()), + pid <- Process.whereis(adapter_opts[:pool]) do + pool_alive? = + if tesla_adapter() == Tesla.Adapter.Gun do + if pid, do: Process.alive?(pid), else: false + else + false + end + + request_opts = + adapter_opts + |> Enum.into(%{}) + |> Map.put(:env, Pleroma.Config.get([:env])) + |> Map.put(:pool_alive?, pool_alive?) + + response = + request( + client, + request, + request_opts + ) + + Connection.after_request(adapter_opts) + + response + end + end + + @spec request(Client.t(), keyword(), map()) :: {:ok, Env.t()} | {:error, any()} + def request(%Client{} = client, request, %{env: :test}), do: request_try(client, request) + + def request(%Client{} = client, request, %{body_as: :chunks}) do + request_try(client, request) + end + + def request(%Client{} = client, request, %{pool_alive?: false}) do + request_try(client, request) + end + + def request(%Client{} = client, request, %{pool: pool, timeout: timeout}) do try do - options = - process_request_options(options) - |> process_sni_options(url) + :poolboy.transaction( + pool, + &Pleroma.Pool.Request.execute(&1, client, request, timeout + 500), + timeout + 1_000 + ) + rescue + e -> + {:error, e} + catch + :exit, {:timeout, _} -> + Logger.warn("Receive response from pool failed #{request[:url]}") + {:error, :recv_pool_timeout} - params = Keyword.get(options, :params, []) + :exit, e -> + {:error, e} + end + end - %{} - |> Builder.method(method) - |> Builder.headers(headers) - |> Builder.opts(options) - |> Builder.url(url) - |> Builder.add_param(:body, :body, body) - |> Builder.add_param(:query, :query, params) - |> Enum.into([]) - |> (&Tesla.request(Connection.new(options), &1)).() + @spec request_try(Client.t(), keyword()) :: {:ok, Env.t()} | {:error, any()} + def request_try(client, request) do + try do + Tesla.request(client, request) rescue e -> {:error, e} @@ -52,35 +133,16 @@ def request(method, url, body \\ "", headers \\ [], options \\ []) do end end - defp process_sni_options(options, nil), do: options - - defp process_sni_options(options, url) do - uri = URI.parse(url) - host = uri.host |> to_charlist() - - case uri.scheme do - "https" -> options ++ [ssl: [server_name_indication: host]] - _ -> options - end + defp build_request(method, headers, options, url, body, params) do + Builder.new() + |> Builder.method(method) + |> Builder.headers(headers) + |> Builder.opts(options) + |> Builder.url(url) + |> Builder.add_param(:body, :body, body) + |> Builder.add_param(:query, :query, params) + |> Builder.convert_to_keyword() end - def process_request_options(options) do - Keyword.merge(Pleroma.HTTP.Connection.hackney_options([]), options) - end - - @doc """ - Performs GET request. - - See `Pleroma.HTTP.request/5` - """ - def get(url, headers \\ [], options \\ []), - do: request(:get, url, "", headers, options) - - @doc """ - Performs POST request. - - See `Pleroma.HTTP.request/5` - """ - def post(url, body, headers \\ [], options \\ []), - do: request(:post, url, body, headers, options) + defp tesla_adapter, do: Application.get_env(:tesla, :adapter) end diff --git a/lib/pleroma/http/request.ex b/lib/pleroma/http/request.ex new file mode 100644 index 000000000..891d88d53 --- /dev/null +++ b/lib/pleroma/http/request.ex @@ -0,0 +1,23 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.Request do + @moduledoc """ + Request struct. + """ + defstruct method: :get, url: "", query: [], headers: [], body: "", opts: [] + + @type method :: :head | :get | :delete | :trace | :options | :post | :put | :patch + @type url :: String.t() + @type headers :: [{String.t(), String.t()}] + + @type t :: %__MODULE__{ + method: method(), + url: url(), + query: keyword(), + headers: headers(), + body: String.t(), + opts: keyword() + } +end diff --git a/lib/pleroma/http/request_builder.ex b/lib/pleroma/http/request_builder.ex index e23457999..491acd0f9 100644 --- a/lib/pleroma/http/request_builder.ex +++ b/lib/pleroma/http/request_builder.ex @@ -7,77 +7,54 @@ defmodule Pleroma.HTTP.RequestBuilder do Helper functions for building Tesla requests """ + alias Pleroma.HTTP.Request + alias Tesla.Multipart + @doc """ - Specify the request method when building a request - - ## Parameters - - - request (Map) - Collected request options - - m (atom) - Request method - - ## Returns - - Map + Creates new request """ - @spec method(map(), atom) :: map() - def method(request, m) do - Map.put_new(request, :method, m) - end + @spec new(Request.t()) :: Request.t() + def new(%Request{} = request \\ %Request{}), do: request @doc """ Specify the request method when building a request - - ## Parameters - - - request (Map) - Collected request options - - u (String) - Request URL - - ## Returns - - Map """ - @spec url(map(), String.t()) :: map() - def url(request, u) do - Map.put_new(request, :url, u) - end + @spec method(Request.t(), Request.method()) :: Request.t() + def method(request, m), do: %{request | method: m} + + @doc """ + Specify the request method when building a request + """ + @spec url(Request.t(), Request.url()) :: Request.t() + def url(request, u), do: %{request | url: u} @doc """ Add headers to the request """ - @spec headers(map(), list(tuple)) :: map() - def headers(request, header_list) do - header_list = + @spec headers(Request.t(), Request.headers()) :: Request.t() + def headers(request, headers) do + headers_list = if Pleroma.Config.get([:http, :send_user_agent]) do - header_list ++ [{"User-Agent", Pleroma.Application.user_agent()}] + headers ++ [{"user-agent", Pleroma.Application.user_agent()}] else - header_list + headers end - Map.put_new(request, :headers, header_list) + %{request | headers: headers_list} end @doc """ Add custom, per-request middleware or adapter options to the request """ - @spec opts(map(), Keyword.t()) :: map() - def opts(request, options) do - Map.put_new(request, :opts, options) - end + @spec opts(Request.t(), keyword()) :: Request.t() + def opts(request, options), do: %{request | opts: options} + # NOTE: isn't used anywhere @doc """ Add optional parameters to the request - ## Parameters - - - request (Map) - Collected request options - - definitions (Map) - Map of parameter name to parameter location. - - options (KeywordList) - The provided optional parameters - - ## Returns - - Map """ - @spec add_optional_params(map(), %{optional(atom) => atom}, keyword()) :: map() + @spec add_optional_params(Request.t(), %{optional(atom) => atom}, keyword()) :: map() def add_optional_params(request, _, []), do: request def add_optional_params(request, definitions, [{key, value} | tail]) do @@ -94,49 +71,43 @@ def add_optional_params(request, definitions, [{key, value} | tail]) do @doc """ Add optional parameters to the request - - ## Parameters - - - request (Map) - Collected request options - - location (atom) - Where to put the parameter - - key (atom) - The name of the parameter - - value (any) - The value of the parameter - - ## Returns - - Map """ - @spec add_param(map(), atom, atom, any()) :: map() - def add_param(request, :query, :query, values), do: Map.put(request, :query, values) + @spec add_param(Request.t(), atom(), atom(), any()) :: Request.t() + def add_param(request, :query, :query, values), do: %{request | query: values} - def add_param(request, :body, :body, value), do: Map.put(request, :body, value) + def add_param(request, :body, :body, value), do: %{request | body: value} def add_param(request, :body, key, value) do request - |> Map.put_new_lazy(:body, &Tesla.Multipart.new/0) + |> Map.put(:body, Multipart.new()) |> Map.update!( :body, - &Tesla.Multipart.add_field( + &Multipart.add_field( &1, key, Jason.encode!(value), - headers: [{:"Content-Type", "application/json"}] + headers: [{"content-type", "application/json"}] ) ) end def add_param(request, :file, name, path) do request - |> Map.put_new_lazy(:body, &Tesla.Multipart.new/0) - |> Map.update!(:body, &Tesla.Multipart.add_file(&1, path, name: name)) + |> Map.put(:body, Multipart.new()) + |> Map.update!(:body, &Multipart.add_file(&1, path, name: name)) end def add_param(request, :form, name, value) do - request - |> Map.update(:body, %{name => value}, &Map.put(&1, name, value)) + Map.update(request, :body, %{name => value}, &Map.put(&1, name, value)) end def add_param(request, location, key, value) do Map.update(request, location, [{key, value}], &(&1 ++ [{key, value}])) end + + def convert_to_keyword(request) do + request + |> Map.from_struct() + |> Enum.into([]) + end end diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 037c42339..5e9bf1574 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -137,7 +137,7 @@ defp make_signature(id, date) do date: date }) - [{:Signature, signature}] + [{"signature", signature}] end defp sign_fetch(headers, id, date) do @@ -150,7 +150,7 @@ defp sign_fetch(headers, id, date) do defp maybe_date_fetch(headers, date) do if Pleroma.Config.get([:activitypub, :sign_object_fetches]) do - headers ++ [{:Date, date}] + headers ++ [{"date", date}] else headers end @@ -162,7 +162,7 @@ def fetch_and_contain_remote_object_from_id(id) when is_binary(id) do date = Pleroma.Signature.signed_date() headers = - [{:Accept, "application/activity+json"}] + [{"accept", "application/activity+json"}] |> maybe_date_fetch(date) |> sign_fetch(id, date) diff --git a/lib/pleroma/otp_version.ex b/lib/pleroma/otp_version.ex new file mode 100644 index 000000000..0be189304 --- /dev/null +++ b/lib/pleroma/otp_version.ex @@ -0,0 +1,63 @@ +defmodule Pleroma.OTPVersion do + @type check_status() :: :undefined | {:error, String.t()} | :ok + + require Logger + + @spec check_version() :: check_status() + def check_version do + # OTP Version https://erlang.org/doc/system_principles/versions.html#otp-version + paths = [ + Path.join(:code.root_dir(), "OTP_VERSION"), + Path.join([:code.root_dir(), "releases", :erlang.system_info(:otp_release), "OTP_VERSION"]) + ] + + :tesla + |> Application.get_env(:adapter) + |> get_and_check_version(paths) + end + + @spec get_and_check_version(module(), [Path.t()]) :: check_status() + def get_and_check_version(Tesla.Adapter.Gun, paths) do + paths + |> check_files() + |> check_version() + end + + def get_and_check_version(_, _), do: :ok + + defp check_files([]), do: nil + + defp check_files([path | paths]) do + if File.exists?(path) do + File.read!(path) + else + check_files(paths) + end + end + + defp check_version(nil), do: :undefined + + defp check_version(version) do + try do + version = String.replace(version, ~r/\r|\n|\s/, "") + + formatted = + version + |> String.split(".") + |> Enum.map(&String.to_integer/1) + |> Enum.take(2) + + with [major, minor] when length(formatted) == 2 <- formatted, + true <- (major == 22 and minor >= 2) or major > 22 do + :ok + else + false -> {:error, version} + _ -> :undefined + end + rescue + _ -> :undefined + catch + _ -> :undefined + end + end +end diff --git a/lib/pleroma/pool/connections.ex b/lib/pleroma/pool/connections.ex new file mode 100644 index 000000000..1ed16d1c1 --- /dev/null +++ b/lib/pleroma/pool/connections.ex @@ -0,0 +1,415 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Pool.Connections do + use GenServer + + require Logger + + @type domain :: String.t() + @type conn :: Pleroma.Gun.Conn.t() + + @type t :: %__MODULE__{ + conns: %{domain() => conn()}, + opts: keyword() + } + + defstruct conns: %{}, opts: [] + + alias Pleroma.Gun.API + alias Pleroma.Gun.Conn + + @spec start_link({atom(), keyword()}) :: {:ok, pid()} + def start_link({name, opts}) do + GenServer.start_link(__MODULE__, opts, name: name) + end + + @impl true + def init(opts), do: {:ok, %__MODULE__{conns: %{}, opts: opts}} + + @spec checkin(String.t() | URI.t(), atom()) :: pid() | nil + def checkin(url, name) + def checkin(url, name) when is_binary(url), do: checkin(URI.parse(url), name) + + def checkin(%URI{} = uri, name) do + timeout = Pleroma.Config.get([:connections_pool, :receive_connection_timeout], 250) + + GenServer.call( + name, + {:checkin, uri}, + timeout + ) + end + + @spec open_conn(String.t() | URI.t(), atom(), keyword()) :: :ok + def open_conn(url, name, opts \\ []) + def open_conn(url, name, opts) when is_binary(url), do: open_conn(URI.parse(url), name, opts) + + def open_conn(%URI{} = uri, name, opts) do + pool_opts = Pleroma.Config.get([:connections_pool], []) + + opts = + opts + |> Enum.into(%{}) + |> Map.put_new(:receive, false) + |> Map.put_new(:retry, pool_opts[:retry] || 5) + |> Map.put_new(:retry_timeout, pool_opts[:retry_timeout] || 100) + |> Map.put_new(:await_up_timeout, pool_opts[:await_up_timeout] || 5_000) + + GenServer.cast(name, {:open_conn, %{opts: opts, uri: uri}}) + end + + @spec alive?(atom()) :: boolean() + def alive?(name) do + pid = Process.whereis(name) + if pid, do: Process.alive?(pid), else: false + end + + @spec get_state(atom()) :: t() + def get_state(name) do + GenServer.call(name, :state) + end + + @spec checkout(pid(), pid(), atom()) :: :ok + def checkout(conn, pid, name) do + GenServer.cast(name, {:checkout, conn, pid}) + end + + @impl true + def handle_cast({:open_conn, %{opts: opts, uri: uri}}, state) do + Logger.debug("opening new #{compose_uri(uri)}") + max_connections = state.opts[:max_connections] + + key = compose_key(uri) + + if Enum.count(state.conns) < max_connections do + open_conn(key, uri, state, opts) + else + try_to_open_conn(key, uri, state, opts) + end + end + + @impl true + def handle_cast({:checkout, conn_pid, pid}, state) do + Logger.debug("checkout #{inspect(conn_pid)}") + + state = + with true <- Process.alive?(conn_pid), + {key, conn} <- find_conn(state.conns, conn_pid), + used_by <- List.keydelete(conn.used_by, pid, 0) do + conn_state = + if used_by == [] do + :idle + else + conn.conn_state + end + + put_in(state.conns[key], %{conn | conn_state: conn_state, used_by: used_by}) + else + false -> + Logger.warn("checkout for closed conn #{inspect(conn_pid)}") + state + + nil -> + Logger.info("checkout for alive conn #{inspect(conn_pid)}, but is not in state") + state + end + + {:noreply, state} + end + + @impl true + def handle_call({:checkin, uri}, from, state) do + Logger.debug("checkin #{compose_uri(uri)}") + key = compose_key(uri) + + case state.conns[key] do + %{conn: conn, gun_state: gun_state} = current_conn when gun_state == :up -> + Logger.debug("reusing conn #{compose_uri(uri)}") + + with time <- :os.system_time(:second), + last_reference <- time - current_conn.last_reference, + current_crf <- crf(last_reference, 100, current_conn.crf), + state <- + put_in(state.conns[key], %{ + current_conn + | last_reference: time, + crf: current_crf, + conn_state: :active, + used_by: [from | current_conn.used_by] + }) do + {:reply, conn, state} + end + + %{gun_state: gun_state} when gun_state == :down -> + {:reply, nil, state} + + nil -> + {:reply, nil, state} + end + end + + @impl true + def handle_call(:state, _from, state), do: {:reply, state, state} + + @impl true + def handle_info({:gun_up, conn_pid, _protocol}, state) do + state = + with true <- Process.alive?(conn_pid), + conn_key when is_binary(conn_key) <- compose_key_gun_info(conn_pid), + {key, conn} <- find_conn(state.conns, conn_pid, conn_key), + time <- :os.system_time(:second), + last_reference <- time - conn.last_reference, + current_crf <- crf(last_reference, 100, conn.crf) do + put_in(state.conns[key], %{ + conn + | gun_state: :up, + last_reference: time, + crf: current_crf, + conn_state: :active, + retries: 0 + }) + else + :error_gun_info -> + Logger.warn(":gun.info caused error") + state + + false -> + Logger.warn(":gun_up message for closed conn #{inspect(conn_pid)}") + state + + nil -> + Logger.warn( + ":gun_up message for alive conn #{inspect(conn_pid)}, but deleted from state" + ) + + :ok = API.close(conn_pid) + + state + end + + {:noreply, state} + end + + @impl true + def handle_info({:gun_down, conn_pid, _protocol, _reason, _killed}, state) do + # we can't get info on this pid, because pid is dead + state = + with true <- Process.alive?(conn_pid), + {key, conn} <- find_conn(state.conns, conn_pid) do + if conn.retries == 5 do + Logger.debug("closing conn if retries is eq 5 #{inspect(conn_pid)}") + :ok = API.close(conn.conn) + + put_in( + state.conns, + Map.delete(state.conns, key) + ) + else + put_in(state.conns[key], %{ + conn + | gun_state: :down, + retries: conn.retries + 1 + }) + end + else + false -> + # gun can send gun_down for closed conn, maybe connection is not closed yet + Logger.warn(":gun_down message for closed conn #{inspect(conn_pid)}") + state + + nil -> + Logger.warn( + ":gun_down message for alive conn #{inspect(conn_pid)}, but deleted from state" + ) + + :ok = API.close(conn_pid) + + state + end + + {:noreply, state} + end + + defp compose_key(%URI{scheme: scheme, host: host, port: port}), do: "#{scheme}:#{host}:#{port}" + + defp compose_key_gun_info(pid) do + try do + # sometimes :gun.info can raise MatchError, which lead to pool terminate + %{origin_host: origin_host, origin_scheme: scheme, origin_port: port} = API.info(pid) + + host = + case :inet.ntoa(origin_host) do + {:error, :einval} -> origin_host + ip -> ip + end + + "#{scheme}:#{host}:#{port}" + rescue + _ -> :error_gun_info + end + end + + defp find_conn(conns, conn_pid) do + Enum.find(conns, fn {_key, conn} -> + conn.conn == conn_pid + end) + end + + defp find_conn(conns, conn_pid, conn_key) do + Enum.find(conns, fn {key, conn} -> + key == conn_key and conn.conn == conn_pid + end) + end + + defp open_conn(key, uri, state, %{proxy: {proxy_host, proxy_port}} = opts) do + connect_opts = + uri + |> destination_opts() + |> add_http2_opts(uri.scheme, Map.get(opts, :tls_opts, [])) + + with open_opts <- Map.delete(opts, :tls_opts), + {:ok, conn} <- API.open(proxy_host, proxy_port, open_opts), + {:ok, _} <- API.await_up(conn), + stream <- API.connect(conn, connect_opts), + {:response, :fin, 200, _} <- API.await(conn, stream), + state <- + put_in(state.conns[key], %Conn{ + conn: conn, + gun_state: :up, + conn_state: :active, + last_reference: :os.system_time(:second) + }) do + {:noreply, state} + else + error -> + Logger.warn( + "Received error on opening connection with http proxy #{uri.scheme}://#{ + compose_uri(uri) + }: #{inspect(error)}" + ) + + {:noreply, state} + end + end + + defp open_conn(key, uri, state, %{proxy: {proxy_type, proxy_host, proxy_port}} = opts) do + version = + proxy_type + |> to_string() + |> String.last() + |> case do + "4" -> 4 + _ -> 5 + end + + socks_opts = + uri + |> destination_opts() + |> add_http2_opts(uri.scheme, Map.get(opts, :tls_opts, [])) + |> Map.put(:version, version) + + opts = + opts + |> Map.put(:protocols, [:socks]) + |> Map.put(:socks_opts, socks_opts) + + with {:ok, conn} <- API.open(proxy_host, proxy_port, opts), + {:ok, _} <- API.await_up(conn), + state <- + put_in(state.conns[key], %Conn{ + conn: conn, + gun_state: :up, + conn_state: :active, + last_reference: :os.system_time(:second) + }) do + {:noreply, state} + else + error -> + Logger.warn( + "Received error on opening connection with socks proxy #{uri.scheme}://#{ + compose_uri(uri) + }: #{inspect(error)}" + ) + + {:noreply, state} + end + end + + defp open_conn(key, %URI{host: host, port: port} = uri, state, opts) do + Logger.debug("opening conn #{compose_uri(uri)}") + {_type, host} = Pleroma.HTTP.Adapter.domain_or_ip(host) + + with {:ok, conn} <- API.open(host, port, opts), + {:ok, _} <- API.await_up(conn), + state <- + put_in(state.conns[key], %Conn{ + conn: conn, + gun_state: :up, + conn_state: :active, + last_reference: :os.system_time(:second) + }) do + Logger.debug("new conn opened #{compose_uri(uri)}") + Logger.debug("replying to the call #{compose_uri(uri)}") + {:noreply, state} + else + error -> + Logger.warn( + "Received error on opening connection #{uri.scheme}://#{compose_uri(uri)}: #{ + inspect(error) + }" + ) + + {:noreply, state} + end + end + + defp destination_opts(%URI{host: host, port: port}) do + {_type, host} = Pleroma.HTTP.Adapter.domain_or_ip(host) + %{host: host, port: port} + end + + defp add_http2_opts(opts, "https", tls_opts) do + Map.merge(opts, %{protocols: [:http2], transport: :tls, tls_opts: tls_opts}) + end + + defp add_http2_opts(opts, _, _), do: opts + + @spec get_unused_conns(map()) :: [{domain(), conn()}] + def get_unused_conns(conns) do + conns + |> Enum.filter(fn {_k, v} -> + v.conn_state == :idle and v.used_by == [] + end) + |> Enum.sort(fn {_x_k, x}, {_y_k, y} -> + x.crf <= y.crf and x.last_reference <= y.last_reference + end) + end + + defp try_to_open_conn(key, uri, state, opts) do + Logger.debug("try to open conn #{compose_uri(uri)}") + + with [{close_key, least_used} | _conns] <- get_unused_conns(state.conns), + :ok <- API.close(least_used.conn), + state <- + put_in( + state.conns, + Map.delete(state.conns, close_key) + ) do + Logger.debug( + "least used conn found and closed #{inspect(least_used.conn)} #{compose_uri(uri)}" + ) + + open_conn(key, uri, state, opts) + else + [] -> {:noreply, state} + end + end + + def crf(current, steps, crf) do + 1 + :math.pow(0.5, current / steps) * crf + end + + def compose_uri(%URI{} = uri), do: "#{uri.host}#{uri.path}" +end diff --git a/lib/pleroma/pool/pool.ex b/lib/pleroma/pool/pool.ex new file mode 100644 index 000000000..a7ae64ce4 --- /dev/null +++ b/lib/pleroma/pool/pool.ex @@ -0,0 +1,22 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Pool do + def child_spec(opts) do + poolboy_opts = + opts + |> Keyword.put(:worker_module, Pleroma.Pool.Request) + |> Keyword.put(:name, {:local, opts[:name]}) + |> Keyword.put(:size, opts[:size]) + |> Keyword.put(:max_overflow, opts[:max_overflow]) + + %{ + id: opts[:id] || {__MODULE__, make_ref()}, + start: {:poolboy, :start_link, [poolboy_opts, [name: opts[:name]]]}, + restart: :permanent, + shutdown: 5000, + type: :worker + } + end +end diff --git a/lib/pleroma/pool/request.ex b/lib/pleroma/pool/request.ex new file mode 100644 index 000000000..2c3574561 --- /dev/null +++ b/lib/pleroma/pool/request.ex @@ -0,0 +1,72 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Pool.Request do + use GenServer + + require Logger + + def start_link(args) do + GenServer.start_link(__MODULE__, args) + end + + @impl true + def init(_), do: {:ok, []} + + @spec execute(pid() | atom(), Tesla.Client.t(), keyword(), pos_integer()) :: + {:ok, Tesla.Env.t()} | {:error, any()} + def execute(pid, client, request, timeout) do + GenServer.call(pid, {:execute, client, request}, timeout) + end + + @impl true + def handle_call({:execute, client, request}, _from, state) do + response = Pleroma.HTTP.request_try(client, request) + + {:reply, response, state} + end + + @impl true + def handle_info({:gun_data, _conn, stream, _, _}, state) do + # in some cases if we reuse conn and got {:error, :body_too_large} + # gun continues to send messages to this process, + # so we flush messages for this request + :ok = :gun.flush(stream) + + {:noreply, state} + end + + @impl true + def handle_info({:gun_up, _conn, _protocol}, state) do + {:noreply, state} + end + + @impl true + def handle_info({:gun_down, _conn, _protocol, _reason, _killed}, state) do + # don't flush messages here, because gun can reconnect + {:noreply, state} + end + + @impl true + def handle_info({:gun_error, _conn, stream, _error}, state) do + :ok = :gun.flush(stream) + {:noreply, state} + end + + @impl true + def handle_info({:gun_push, _conn, _stream, _new_stream, _method, _uri, _headers}, state) do + {:noreply, state} + end + + @impl true + def handle_info({:gun_response, _conn, _stream, _, _status, _headers}, state) do + {:noreply, state} + end + + @impl true + def handle_info(msg, state) do + Logger.warn("Received unexpected message #{inspect(__MODULE__)} #{inspect(msg)}") + {:noreply, state} + end +end diff --git a/lib/pleroma/pool/supervisor.ex b/lib/pleroma/pool/supervisor.ex new file mode 100644 index 000000000..32be2264d --- /dev/null +++ b/lib/pleroma/pool/supervisor.ex @@ -0,0 +1,36 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Pool.Supervisor do + use Supervisor + + alias Pleroma.Pool + + def start_link(args) do + Supervisor.start_link(__MODULE__, args, name: __MODULE__) + end + + def init(_) do + children = + [ + %{ + id: Pool.Connections, + start: + {Pool.Connections, :start_link, + [{:gun_connections, Pleroma.Config.get([:connections_pool])}]} + } + ] ++ pools() + + Supervisor.init(children, strategy: :one_for_one) + end + + defp pools do + for {pool_name, pool_opts} <- Pleroma.Config.get([:pools]) do + pool_opts + |> Keyword.put(:id, {Pool, pool_name}) + |> Keyword.put(:name, pool_name) + |> Pool.child_spec() + end + end +end diff --git a/lib/pleroma/reverse_proxy/client.ex b/lib/pleroma/reverse_proxy/client.ex index 776c4794c..63261b94c 100644 --- a/lib/pleroma/reverse_proxy/client.ex +++ b/lib/pleroma/reverse_proxy/client.ex @@ -3,19 +3,23 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.ReverseProxy.Client do - @callback request(atom(), String.t(), [tuple()], String.t(), list()) :: - {:ok, pos_integer(), [tuple()], reference() | map()} - | {:ok, pos_integer(), [tuple()]} + @type status :: pos_integer() + @type header_name :: String.t() + @type header_value :: String.t() + @type headers :: [{header_name(), header_value()}] + + @callback request(atom(), String.t(), headers(), String.t(), list()) :: + {:ok, status(), headers(), reference() | map()} + | {:ok, status(), headers()} | {:ok, reference()} | {:error, term()} - @callback stream_body(reference() | pid() | map()) :: - {:ok, binary()} | :done | {:error, String.t()} + @callback stream_body(map()) :: {:ok, binary(), map()} | :done | {:error, atom() | String.t()} @callback close(reference() | pid() | map()) :: :ok - def request(method, url, headers, "", opts \\ []) do - client().request(method, url, headers, "", opts) + def request(method, url, headers, body \\ "", opts \\ []) do + client().request(method, url, headers, body, opts) end def stream_body(ref), do: client().stream_body(ref) @@ -23,6 +27,12 @@ def stream_body(ref), do: client().stream_body(ref) def close(ref), do: client().close(ref) defp client do - Pleroma.Config.get([Pleroma.ReverseProxy.Client], :hackney) + :tesla + |> Application.get_env(:adapter) + |> client() end + + defp client(Tesla.Adapter.Hackney), do: Pleroma.ReverseProxy.Client.Hackney + defp client(Tesla.Adapter.Gun), do: Pleroma.ReverseProxy.Client.Tesla + defp client(_), do: Pleroma.Config.get!(Pleroma.ReverseProxy.Client) end diff --git a/lib/pleroma/reverse_proxy/client/hackney.ex b/lib/pleroma/reverse_proxy/client/hackney.ex new file mode 100644 index 000000000..e41560ab0 --- /dev/null +++ b/lib/pleroma/reverse_proxy/client/hackney.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.ReverseProxy.Client.Hackney do + @behaviour Pleroma.ReverseProxy.Client + + @impl true + def request(method, url, headers, body, opts \\ []) do + :hackney.request(method, url, headers, body, opts) + end + + @impl true + def stream_body(ref) do + case :hackney.stream_body(ref) do + :done -> :done + {:ok, data} -> {:ok, data, ref} + {:error, error} -> {:error, error} + end + end + + @impl true + def close(ref), do: :hackney.close(ref) +end diff --git a/lib/pleroma/reverse_proxy/client/tesla.ex b/lib/pleroma/reverse_proxy/client/tesla.ex new file mode 100644 index 000000000..55a11b4a8 --- /dev/null +++ b/lib/pleroma/reverse_proxy/client/tesla.ex @@ -0,0 +1,87 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ReverseProxy.Client.Tesla do + @type headers() :: [{String.t(), String.t()}] + @type status() :: pos_integer() + + @behaviour Pleroma.ReverseProxy.Client + + @spec request(atom(), String.t(), headers(), String.t(), keyword()) :: + {:ok, status(), headers} + | {:ok, status(), headers, map()} + | {:error, atom() | String.t()} + | no_return() + + @impl true + def request(method, url, headers, body, opts \\ []) do + _adapter = check_adapter() + + with opts <- Keyword.merge(opts, body_as: :chunks, mode: :passive), + {:ok, response} <- + Pleroma.HTTP.request( + method, + url, + body, + headers, + Keyword.put(opts, :adapter, opts) + ) do + if is_map(response.body) and method != :head do + {:ok, response.status, response.headers, response.body} + else + {:ok, response.status, response.headers} + end + else + {:error, error} -> {:error, error} + end + end + + @impl true + @spec stream_body(map()) :: {:ok, binary(), map()} | {:error, atom() | String.t()} | :done + def stream_body(%{pid: pid, opts: opts, fin: true}) do + # if connection was sended and there were redirects, we need to close new conn - pid manually + if opts[:old_conn], do: Tesla.Adapter.Gun.close(pid) + # if there were redirects we need to checkout old conn + conn = opts[:old_conn] || opts[:conn] + + if conn, do: :ok = Pleroma.Pool.Connections.checkout(conn, self(), :gun_connections) + + :done + end + + def stream_body(client) do + case read_chunk!(client) do + {:fin, body} -> + {:ok, body, Map.put(client, :fin, true)} + + {:nofin, part} -> + {:ok, part, client} + + {:error, error} -> + {:error, error} + end + end + + defp read_chunk!(%{pid: pid, stream: stream, opts: opts}) do + adapter = check_adapter() + adapter.read_chunk(pid, stream, opts) + end + + @impl true + @spec close(map) :: :ok | no_return() + def close(%{pid: pid}) do + adapter = check_adapter() + adapter.close(pid) + end + + defp check_adapter do + adapter = Application.get_env(:tesla, :adapter) + + unless adapter == Tesla.Adapter.Gun do + raise "#{adapter} doesn't support reading body in chunks" + end + + adapter + end +end diff --git a/lib/pleroma/reverse_proxy/reverse_proxy.ex b/lib/pleroma/reverse_proxy/reverse_proxy.ex index 2ed719315..9f5710c92 100644 --- a/lib/pleroma/reverse_proxy/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy/reverse_proxy.ex @@ -3,8 +3,6 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.ReverseProxy do - alias Pleroma.HTTP - @keep_req_headers ~w(accept user-agent accept-encoding cache-control if-modified-since) ++ ~w(if-unmodified-since if-none-match if-range range) @resp_cache_headers ~w(etag date last-modified cache-control) @@ -61,10 +59,10 @@ defmodule Pleroma.ReverseProxy do * `req_headers`, `resp_headers` additional headers. - * `http`: options for [hackney](https://github.com/benoitc/hackney). + * `http`: options for [gun](https://github.com/ninenines/gun). """ - @default_hackney_options [pool: :media] + @default_options [pool: :media] @inline_content_types [ "image/gif", @@ -97,11 +95,7 @@ defmodule Pleroma.ReverseProxy do def call(_conn, _url, _opts \\ []) def call(conn = %{method: method}, url, opts) when method in @methods do - hackney_opts = - Pleroma.HTTP.Connection.hackney_options([]) - |> Keyword.merge(@default_hackney_options) - |> Keyword.merge(Keyword.get(opts, :http, [])) - |> HTTP.process_request_options() + client_opts = Keyword.merge(@default_options, Keyword.get(opts, :http, [])) req_headers = build_req_headers(conn.req_headers, opts) @@ -113,7 +107,7 @@ def call(conn = %{method: method}, url, opts) when method in @methods do end with {:ok, nil} <- Cachex.get(:failed_proxy_url_cache, url), - {:ok, code, headers, client} <- request(method, url, req_headers, hackney_opts), + {:ok, code, headers, client} <- request(method, url, req_headers, client_opts), :ok <- header_length_constraint( headers, @@ -159,11 +153,11 @@ def call(conn, _, _) do |> halt() end - defp request(method, url, headers, hackney_opts) do + defp request(method, url, headers, opts) do Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}") method = method |> String.downcase() |> String.to_existing_atom() - case client().request(method, url, headers, "", hackney_opts) do + case client().request(method, url, headers, "", opts) do {:ok, code, headers, client} when code in @valid_resp_codes -> {:ok, code, downcase_headers(headers), client} @@ -213,7 +207,7 @@ defp chunk_reply(conn, client, opts, sent_so_far, duration) do duration, Keyword.get(opts, :max_read_duration, @max_read_duration) ), - {:ok, data} <- client().stream_body(client), + {:ok, data, client} <- client().stream_body(client), {:ok, duration} <- increase_read_duration(duration), sent_so_far = sent_so_far + byte_size(data), :ok <- diff --git a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex index df774b0f7..ade87daf2 100644 --- a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex @@ -12,17 +12,23 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do require Logger - @hackney_options [ - pool: :media, - recv_timeout: 10_000 + @options [ + pool: :media ] def perform(:prefetch, url) do Logger.debug("Prefetching #{inspect(url)}") + opts = + if Application.get_env(:tesla, :adapter) == Tesla.Adapter.Hackney do + Keyword.put(@options, :recv_timeout, 10_000) + else + @options + end + url |> MediaProxy.url() - |> HTTP.get([], adapter: @hackney_options) + |> HTTP.get([], adapter: opts) end def perform(:preload, %{"object" => %{"attachment" => attachments}} = _message) do diff --git a/lib/pleroma/web/rel_me.ex b/lib/pleroma/web/rel_me.ex index 16b1a53d2..0ae926375 100644 --- a/lib/pleroma/web/rel_me.ex +++ b/lib/pleroma/web/rel_me.ex @@ -3,11 +3,9 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.RelMe do - @hackney_options [ + @options [ pool: :media, - recv_timeout: 2_000, - max_body: 2_000_000, - with_body: true + max_body: 2_000_000 ] if Pleroma.Config.get(:env) == :test do @@ -25,8 +23,18 @@ def parse(url) when is_binary(url) do def parse(_), do: {:error, "No URL provided"} defp parse_url(url) do + opts = + if Application.get_env(:tesla, :adapter) == Tesla.Adapter.Hackney do + Keyword.merge(@options, + recv_timeout: 2_000, + with_body: true + ) + else + @options + end + with {:ok, %Tesla.Env{body: html, status: status}} when status in 200..299 <- - Pleroma.HTTP.get(url, [], adapter: @hackney_options), + Pleroma.HTTP.get(url, [], adapter: opts), data <- Floki.attribute(html, "link[rel~=me]", "href") ++ Floki.attribute(html, "a[rel~=me]", "href") do diff --git a/lib/pleroma/web/rich_media/parser.ex b/lib/pleroma/web/rich_media/parser.ex index c06b0a0f2..9deb03845 100644 --- a/lib/pleroma/web/rich_media/parser.ex +++ b/lib/pleroma/web/rich_media/parser.ex @@ -3,11 +3,9 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.RichMedia.Parser do - @hackney_options [ + @options [ pool: :media, - recv_timeout: 2_000, - max_body: 2_000_000, - with_body: true + max_body: 2_000_000 ] defp parsers do @@ -77,8 +75,18 @@ defp get_ttl_from_image(data, url) do end defp parse_url(url) do + opts = + if Application.get_env(:tesla, :adapter) == Tesla.Adapter.Hackney do + Keyword.merge(@options, + recv_timeout: 2_000, + with_body: true + ) + else + @options + end + try do - {:ok, %Tesla.Env{body: html}} = Pleroma.HTTP.get(url, [], adapter: @hackney_options) + {:ok, %Tesla.Env{body: html}} = Pleroma.HTTP.get(url, [], adapter: opts) html |> parse_html diff --git a/lib/pleroma/web/web_finger/web_finger.ex b/lib/pleroma/web/web_finger/web_finger.ex index b4cc80179..91e9e2271 100644 --- a/lib/pleroma/web/web_finger/web_finger.ex +++ b/lib/pleroma/web/web_finger/web_finger.ex @@ -205,7 +205,7 @@ def finger(account) do with response <- HTTP.get( address, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ), {:ok, %{status: status, body: body}} when status in 200..299 <- response do doc = XML.parse_document(body) diff --git a/mix.exs b/mix.exs index b28c65694..7c6de5423 100644 --- a/mix.exs +++ b/mix.exs @@ -120,6 +120,10 @@ defp deps do {:cachex, "~> 3.0.2"}, {:poison, "~> 3.0", override: true}, {:tesla, "~> 1.3", override: true}, + {:castore, "~> 0.1"}, + {:cowlib, "~> 2.8", override: true}, + {:gun, + github: "ninenines/gun", ref: "bd6425ab87428cf4c95f4d23e0a48fd065fbd714", override: true}, {:jason, "~> 1.0"}, {:mogrify, "~> 0.6.1"}, {:ex_aws, "~> 2.1"}, diff --git a/mix.lock b/mix.lock index 9c811a974..158a87e47 100644 --- a/mix.lock +++ b/mix.lock @@ -9,6 +9,7 @@ "cachex": {:hex, :cachex, "3.0.3", "4e2d3e05814a5738f5ff3903151d5c25636d72a3527251b753f501ad9c657967", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "3aadb1e605747122f60aa7b0b121cca23c14868558157563b3f3e19ea929f7d0"}, "calendar": {:hex, :calendar, "0.17.6", "ec291cb2e4ba499c2e8c0ef5f4ace974e2f9d02ae9e807e711a9b0c7850b9aee", [:mix], [{:tzdata, "~> 0.5.20 or ~> 0.1.201603 or ~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "738d0e17a93c2ccfe4ddc707bdc8e672e9074c8569498483feb1c4530fb91b2b"}, "captcha": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha.git", "e0f16822d578866e186a0974d65ad58cddc1e2ab", [ref: "e0f16822d578866e186a0974d65ad58cddc1e2ab"]}, + "castore": {:hex, :castore, "0.1.5", "591c763a637af2cc468a72f006878584bc6c306f8d111ef8ba1d4c10e0684010", [:mix], [], "hexpm", "6db356b2bc6cc22561e051ff545c20ad064af57647e436650aa24d7d06cd941a"}, "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "4.1.2", "3eb5620fd8e35508991664b4c2b04dd41e52f1620b36957be837c1d7784b7592", [:mix], [{:argon2_elixir, "~> 1.2", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:bcrypt_elixir, "~> 0.12.1 or ~> 1.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: true]}, {:pbkdf2_elixir, "~> 0.12", [hex: :pbkdf2_elixir, repo: "hexpm", optional: true]}], "hexpm", "d8700a0ca4dbb616c22c9b3f6dd539d88deaafec3efe66869d6370c9a559b3e9"}, @@ -45,6 +46,7 @@ "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", "bd6425ab87428cf4c95f4d23e0a48fd065fbd714", [ref: "bd6425ab87428cf4c95f4d23e0a48fd065fbd714"]}, "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"}, diff --git a/test/activity/ir/topics_test.exs b/test/activity/ir/topics_test.exs index e75f83586..8729e5746 100644 --- a/test/activity/ir/topics_test.exs +++ b/test/activity/ir/topics_test.exs @@ -83,7 +83,7 @@ test "converts tags to hash tags", %{activity: %{object: %{data: data} = object} assert Enum.member?(topics, "hashtag:bar") end - test "only converts strinngs to hash tags", %{ + test "only converts strings to hash tags", %{ activity: %{object: %{data: data} = object} = activity } do tagged_data = Map.put(data, "tag", [2]) diff --git a/test/config/config_db_test.exs b/test/config/config_db_test.exs index 812709fd8..394040a59 100644 --- a/test/config/config_db_test.exs +++ b/test/config/config_db_test.exs @@ -478,14 +478,6 @@ test "simple keyword" do assert ConfigDB.from_binary(binary) == [key: "value"] end - test "keyword with partial_chain key" do - binary = - ConfigDB.transform([%{"tuple" => [":partial_chain", "&:hackney_connect.partial_chain/1"]}]) - - assert binary == :erlang.term_to_binary(partial_chain: &:hackney_connect.partial_chain/1) - assert ConfigDB.from_binary(binary) == [partial_chain: &:hackney_connect.partial_chain/1] - end - test "keyword" do binary = ConfigDB.transform([ diff --git a/test/fixtures/warnings/otp_version/21.1 b/test/fixtures/warnings/otp_version/21.1 new file mode 100644 index 000000000..90cd64c4f --- /dev/null +++ b/test/fixtures/warnings/otp_version/21.1 @@ -0,0 +1 @@ +21.1 \ No newline at end of file diff --git a/test/fixtures/warnings/otp_version/22.1 b/test/fixtures/warnings/otp_version/22.1 new file mode 100644 index 000000000..d9b314368 --- /dev/null +++ b/test/fixtures/warnings/otp_version/22.1 @@ -0,0 +1 @@ +22.1 \ No newline at end of file diff --git a/test/fixtures/warnings/otp_version/22.4 b/test/fixtures/warnings/otp_version/22.4 new file mode 100644 index 000000000..1da8ccd28 --- /dev/null +++ b/test/fixtures/warnings/otp_version/22.4 @@ -0,0 +1 @@ +22.4 \ No newline at end of file diff --git a/test/fixtures/warnings/otp_version/23.0 b/test/fixtures/warnings/otp_version/23.0 new file mode 100644 index 000000000..4266d8634 --- /dev/null +++ b/test/fixtures/warnings/otp_version/23.0 @@ -0,0 +1 @@ +23.0 \ No newline at end of file diff --git a/test/fixtures/warnings/otp_version/error b/test/fixtures/warnings/otp_version/error new file mode 100644 index 000000000..8fdd954df --- /dev/null +++ b/test/fixtures/warnings/otp_version/error @@ -0,0 +1 @@ +22 \ No newline at end of file diff --git a/test/fixtures/warnings/otp_version/undefined b/test/fixtures/warnings/otp_version/undefined new file mode 100644 index 000000000..66dc9051d --- /dev/null +++ b/test/fixtures/warnings/otp_version/undefined @@ -0,0 +1 @@ +undefined \ No newline at end of file diff --git a/test/gun/gun_test.exs b/test/gun/gun_test.exs new file mode 100644 index 000000000..7f185617c --- /dev/null +++ b/test/gun/gun_test.exs @@ -0,0 +1,33 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.GunTest do + use ExUnit.Case + alias Pleroma.Gun + + @moduletag :integration + + test "opens connection and receive response" do + {:ok, conn} = Gun.open('httpbin.org', 443) + assert is_pid(conn) + {:ok, _protocol} = Gun.await_up(conn) + ref = :gun.get(conn, '/get?a=b&c=d') + assert is_reference(ref) + + assert {:response, :nofin, 200, _} = Gun.await(conn, ref) + assert json = receive_response(conn, ref) + + assert %{"args" => %{"a" => "b", "c" => "d"}} = Jason.decode!(json) + end + + defp receive_response(conn, ref, acc \\ "") do + case Gun.await(conn, ref) do + {:data, :nofin, body} -> + receive_response(conn, ref, acc <> body) + + {:data, :fin, body} -> + acc <> body + end + end +end diff --git a/test/http/adapter/gun_test.exs b/test/http/adapter/gun_test.exs new file mode 100644 index 000000000..37489e1a4 --- /dev/null +++ b/test/http/adapter/gun_test.exs @@ -0,0 +1,266 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.Adapter.GunTest do + use ExUnit.Case, async: true + use Pleroma.Tests.Helpers + import ExUnit.CaptureLog + alias Pleroma.Config + alias Pleroma.HTTP.Adapter.Gun + alias Pleroma.Pool.Connections + + setup_all do + {:ok, _} = Registry.start_link(keys: :unique, name: Pleroma.Gun.API.Mock) + :ok + end + + describe "options/1" do + clear_config([:http, :adapter]) do + Config.put([:http, :adapter], a: 1, b: 2) + end + + test "https url with default port" do + uri = URI.parse("https://example.com") + + opts = Gun.options(uri) + assert opts[:certificates_verification] + tls_opts = opts[:tls_opts] + assert tls_opts[:verify] == :verify_peer + assert tls_opts[:depth] == 20 + assert tls_opts[:reuse_sessions] == false + + assert tls_opts[:verify_fun] == + {&:ssl_verify_hostname.verify_fun/3, [check_hostname: 'example.com']} + + assert File.exists?(tls_opts[:cacertfile]) + + assert opts[:original] == "example.com:443" + end + + test "https ipv4 with default port" do + uri = URI.parse("https://127.0.0.1") + + opts = Gun.options(uri) + + assert opts[:tls_opts][:verify_fun] == + {&:ssl_verify_hostname.verify_fun/3, [check_hostname: '127.0.0.1']} + + assert opts[:original] == "127.0.0.1:443" + end + + test "https ipv6 with default port" do + uri = URI.parse("https://[2a03:2880:f10c:83:face:b00c:0:25de]") + + opts = Gun.options(uri) + + assert opts[:tls_opts][:verify_fun] == + {&:ssl_verify_hostname.verify_fun/3, + [check_hostname: '2a03:2880:f10c:83:face:b00c:0:25de']} + + assert opts[:original] == "2a03:2880:f10c:83:face:b00c:0:25de:443" + end + + test "https url with non standart port" do + uri = URI.parse("https://example.com:115") + + opts = Gun.options(uri) + + assert opts[:certificates_verification] + assert opts[:transport] == :tls + end + + test "receive conn by default" do + uri = URI.parse("http://another-domain.com") + :ok = Connections.open_conn(uri, :gun_connections) + + received_opts = Gun.options(uri) + assert received_opts[:close_conn] == false + assert is_pid(received_opts[:conn]) + end + + test "don't receive conn if receive_conn is false" do + uri = URI.parse("http://another-domain2.com") + :ok = Connections.open_conn(uri, :gun_connections) + + opts = [receive_conn: false] + received_opts = Gun.options(opts, uri) + assert received_opts[:close_conn] == nil + assert received_opts[:conn] == nil + end + + test "get conn on next request" do + level = Application.get_env(:logger, :level) + Logger.configure(level: :info) + on_exit(fn -> Logger.configure(level: level) end) + uri = URI.parse("http://some-domain2.com") + + assert capture_log(fn -> + opts = Gun.options(uri) + + assert opts[:conn] == nil + assert opts[:close_conn] == nil + end) =~ + "Gun connections pool checkin was not succesfull. Trying to open conn for next request." + + opts = Gun.options(uri) + + assert is_pid(opts[:conn]) + assert opts[:close_conn] == false + end + + test "merges with defaul http adapter config" do + defaults = Gun.options(URI.parse("https://example.com")) + assert Keyword.has_key?(defaults, :a) + assert Keyword.has_key?(defaults, :b) + end + + test "default ssl adapter opts with connection" do + uri = URI.parse("https://some-domain.com") + + :ok = Connections.open_conn(uri, :gun_connections) + + opts = Gun.options(uri) + + assert opts[:certificates_verification] + tls_opts = opts[:tls_opts] + assert tls_opts[:verify] == :verify_peer + assert tls_opts[:depth] == 20 + assert tls_opts[:reuse_sessions] == false + + assert opts[:original] == "some-domain.com:443" + assert opts[:close_conn] == false + assert is_pid(opts[:conn]) + end + + test "parses string proxy host & port" do + proxy = Config.get([:http, :proxy_url]) + Config.put([:http, :proxy_url], "localhost:8123") + on_exit(fn -> Config.put([:http, :proxy_url], proxy) end) + + uri = URI.parse("https://some-domain.com") + opts = Gun.options([receive_conn: false], uri) + assert opts[:proxy] == {'localhost', 8123} + end + + test "parses tuple proxy scheme host and port" do + proxy = Config.get([:http, :proxy_url]) + Config.put([:http, :proxy_url], {:socks, 'localhost', 1234}) + on_exit(fn -> Config.put([:http, :proxy_url], proxy) end) + + uri = URI.parse("https://some-domain.com") + opts = Gun.options([receive_conn: false], uri) + assert opts[:proxy] == {:socks, 'localhost', 1234} + end + + test "passed opts have more weight than defaults" do + proxy = Config.get([:http, :proxy_url]) + Config.put([:http, :proxy_url], {:socks5, 'localhost', 1234}) + on_exit(fn -> Config.put([:http, :proxy_url], proxy) end) + uri = URI.parse("https://some-domain.com") + opts = Gun.options([receive_conn: false, proxy: {'example.com', 4321}], uri) + + assert opts[:proxy] == {'example.com', 4321} + end + end + + describe "after_request/1" do + test "body_as not chunks" do + uri = URI.parse("http://some-domain.com") + :ok = Connections.open_conn(uri, :gun_connections) + opts = Gun.options(uri) + :ok = Gun.after_request(opts) + conn = opts[:conn] + + assert %Connections{ + conns: %{ + "http:some-domain.com:80" => %Pleroma.Gun.Conn{ + conn: ^conn, + conn_state: :idle, + used_by: [] + } + } + } = Connections.get_state(:gun_connections) + end + + test "body_as chunks" do + uri = URI.parse("http://some-domain.com") + :ok = Connections.open_conn(uri, :gun_connections) + opts = Gun.options([body_as: :chunks], uri) + :ok = Gun.after_request(opts) + conn = opts[:conn] + self = self() + + assert %Connections{ + conns: %{ + "http:some-domain.com:80" => %Pleroma.Gun.Conn{ + conn: ^conn, + conn_state: :active, + used_by: [{^self, _}] + } + } + } = Connections.get_state(:gun_connections) + end + + test "with no connection" do + uri = URI.parse("http://uniq-domain.com") + + :ok = Connections.open_conn(uri, :gun_connections) + + opts = Gun.options([body_as: :chunks], uri) + conn = opts[:conn] + opts = Keyword.delete(opts, :conn) + self = self() + + :ok = Gun.after_request(opts) + + assert %Connections{ + conns: %{ + "http:uniq-domain.com:80" => %Pleroma.Gun.Conn{ + conn: ^conn, + conn_state: :active, + used_by: [{^self, _}] + } + } + } = Connections.get_state(:gun_connections) + end + + test "with ipv4" do + uri = URI.parse("http://127.0.0.1") + :ok = Connections.open_conn(uri, :gun_connections) + opts = Gun.options(uri) + send(:gun_connections, {:gun_up, opts[:conn], :http}) + :ok = Gun.after_request(opts) + conn = opts[:conn] + + assert %Connections{ + conns: %{ + "http:127.0.0.1:80" => %Pleroma.Gun.Conn{ + conn: ^conn, + conn_state: :idle, + used_by: [] + } + } + } = Connections.get_state(:gun_connections) + end + + test "with ipv6" do + uri = URI.parse("http://[2a03:2880:f10c:83:face:b00c:0:25de]") + :ok = Connections.open_conn(uri, :gun_connections) + opts = Gun.options(uri) + send(:gun_connections, {:gun_up, opts[:conn], :http}) + :ok = Gun.after_request(opts) + conn = opts[:conn] + + assert %Connections{ + conns: %{ + "http:2a03:2880:f10c:83:face:b00c:0:25de:80" => %Pleroma.Gun.Conn{ + conn: ^conn, + conn_state: :idle, + used_by: [] + } + } + } = Connections.get_state(:gun_connections) + end + end +end diff --git a/test/http/adapter/hackney_test.exs b/test/http/adapter/hackney_test.exs new file mode 100644 index 000000000..35cb58125 --- /dev/null +++ b/test/http/adapter/hackney_test.exs @@ -0,0 +1,54 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.Adapter.HackneyTest do + use ExUnit.Case + use Pleroma.Tests.Helpers + + alias Pleroma.Config + alias Pleroma.HTTP.Adapter.Hackney + + setup_all do + uri = URI.parse("http://domain.com") + {:ok, uri: uri} + end + + describe "options/2" do + clear_config([:http, :adapter]) do + Config.put([:http, :adapter], a: 1, b: 2) + end + + test "add proxy and opts from config", %{uri: uri} do + proxy = Config.get([:http, :proxy_url]) + Config.put([:http, :proxy_url], "localhost:8123") + on_exit(fn -> Config.put([:http, :proxy_url], proxy) end) + + opts = Hackney.options(uri) + + assert opts[:a] == 1 + assert opts[:b] == 2 + assert opts[:proxy] == "localhost:8123" + end + + test "respect connection opts and no proxy", %{uri: uri} do + opts = Hackney.options([a: 2, b: 1], uri) + + assert opts[:a] == 2 + assert opts[:b] == 1 + refute Keyword.has_key?(opts, :proxy) + end + + test "add opts for https" do + uri = URI.parse("https://domain.com") + + opts = Hackney.options(uri) + + assert opts[:ssl_options] == [ + partial_chain: &:hackney_connect.partial_chain/1, + versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"], + server_name_indication: 'domain.com' + ] + end + end +end diff --git a/test/http/adapter_test.exs b/test/http/adapter_test.exs new file mode 100644 index 000000000..37e47dabe --- /dev/null +++ b/test/http/adapter_test.exs @@ -0,0 +1,65 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.AdapterTest do + use ExUnit.Case, async: true + + alias Pleroma.HTTP.Adapter + + describe "domain_or_ip/1" do + test "with domain" do + assert Adapter.domain_or_ip("example.com") == {:domain, 'example.com'} + end + + test "with idna domain" do + assert Adapter.domain_or_ip("ですexample.com") == {:domain, 'xn--example-183fne.com'} + end + + test "with ipv4" do + assert Adapter.domain_or_ip("127.0.0.1") == {:ip, {127, 0, 0, 1}} + end + + test "with ipv6" do + assert Adapter.domain_or_ip("2a03:2880:f10c:83:face:b00c:0:25de") == + {:ip, {10_755, 10_368, 61_708, 131, 64_206, 45_068, 0, 9_694}} + end + end + + describe "domain_or_fallback/1" do + test "with domain" do + assert Adapter.domain_or_fallback("example.com") == 'example.com' + end + + test "with idna domain" do + assert Adapter.domain_or_fallback("ですexample.com") == 'xn--example-183fne.com' + end + + test "with ipv4" do + assert Adapter.domain_or_fallback("127.0.0.1") == '127.0.0.1' + end + + test "with ipv6" do + assert Adapter.domain_or_fallback("2a03:2880:f10c:83:face:b00c:0:25de") == + '2a03:2880:f10c:83:face:b00c:0:25de' + end + end + + describe "format_proxy/1" do + test "with nil" do + assert Adapter.format_proxy(nil) == nil + end + + test "with string" do + assert Adapter.format_proxy("127.0.0.1:8123") == {{127, 0, 0, 1}, 8123} + end + + test "localhost with port" do + assert Adapter.format_proxy("localhost:8123") == {'localhost', 8123} + end + + test "tuple" do + assert Adapter.format_proxy({:socks4, :localhost, 9050}) == {:socks4, 'localhost', 9050} + end + end +end diff --git a/test/http/connection_test.exs b/test/http/connection_test.exs new file mode 100644 index 000000000..c1ff0cc21 --- /dev/null +++ b/test/http/connection_test.exs @@ -0,0 +1,142 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.ConnectionTest do + use ExUnit.Case + use Pleroma.Tests.Helpers + import ExUnit.CaptureLog + alias Pleroma.Config + alias Pleroma.HTTP.Connection + + setup_all do + {:ok, _} = Registry.start_link(keys: :unique, name: Pleroma.Gun.API.Mock) + :ok + end + + describe "parse_host/1" do + test "as atom to charlist" do + assert Connection.parse_host(:localhost) == 'localhost' + end + + test "as string to charlist" do + assert Connection.parse_host("localhost.com") == 'localhost.com' + end + + test "as string ip to tuple" do + assert Connection.parse_host("127.0.0.1") == {127, 0, 0, 1} + end + end + + describe "parse_proxy/1" do + test "ip with port" do + assert Connection.parse_proxy("127.0.0.1:8123") == {:ok, {127, 0, 0, 1}, 8123} + end + + test "host with port" do + assert Connection.parse_proxy("localhost:8123") == {:ok, 'localhost', 8123} + end + + test "as tuple" do + assert Connection.parse_proxy({:socks4, :localhost, 9050}) == + {:ok, :socks4, 'localhost', 9050} + end + + test "as tuple with string host" do + assert Connection.parse_proxy({:socks5, "localhost", 9050}) == + {:ok, :socks5, 'localhost', 9050} + end + end + + describe "parse_proxy/1 errors" do + test "ip without port" do + capture_log(fn -> + assert Connection.parse_proxy("127.0.0.1") == {:error, :error_parsing_proxy} + end) =~ "parsing proxy fail \"127.0.0.1\"" + end + + test "host without port" do + capture_log(fn -> + assert Connection.parse_proxy("localhost") == {:error, :error_parsing_proxy} + end) =~ "parsing proxy fail \"localhost\"" + end + + test "host with bad port" do + capture_log(fn -> + assert Connection.parse_proxy("localhost:port") == {:error, :error_parsing_port_in_proxy} + end) =~ "parsing port in proxy fail \"localhost:port\"" + end + + test "ip with bad port" do + capture_log(fn -> + assert Connection.parse_proxy("127.0.0.1:15.9") == {:error, :error_parsing_port_in_proxy} + end) =~ "parsing port in proxy fail \"127.0.0.1:15.9\"" + end + + test "as tuple without port" do + capture_log(fn -> + assert Connection.parse_proxy({:socks5, :localhost}) == {:error, :error_parsing_proxy} + end) =~ "parsing proxy fail {:socks5, :localhost}" + end + + test "with nil" do + assert Connection.parse_proxy(nil) == nil + end + end + + describe "options/3" do + clear_config([:http, :proxy_url]) + + test "without proxy_url in config" do + Config.delete([:http, :proxy_url]) + + opts = Connection.options(%URI{}) + refute Keyword.has_key?(opts, :proxy) + end + + test "parses string proxy host & port" do + Config.put([:http, :proxy_url], "localhost:8123") + + opts = Connection.options(%URI{}) + assert opts[:proxy] == {'localhost', 8123} + end + + test "parses tuple proxy scheme host and port" do + Config.put([:http, :proxy_url], {:socks, 'localhost', 1234}) + + opts = Connection.options(%URI{}) + assert opts[:proxy] == {:socks, 'localhost', 1234} + end + + test "passed opts have more weight than defaults" do + Config.put([:http, :proxy_url], {:socks5, 'localhost', 1234}) + + opts = Connection.options(%URI{}, proxy: {'example.com', 4321}) + + assert opts[:proxy] == {'example.com', 4321} + end + + test "default ssl adapter opts with connection" do + adapter = Application.get_env(:tesla, :adapter) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Gun) + on_exit(fn -> Application.put_env(:tesla, :adapter, adapter) end) + + uri = URI.parse("https://some-domain.com") + + pid = Process.whereis(:federation) + :ok = Pleroma.Pool.Connections.open_conn(uri, :gun_connections, genserver_pid: pid) + + opts = Connection.options(uri) + + assert opts[:certificates_verification] + tls_opts = opts[:tls_opts] + assert tls_opts[:verify] == :verify_peer + assert tls_opts[:depth] == 20 + assert tls_opts[:reuse_sessions] == false + + assert opts[:original] == "some-domain.com:443" + assert opts[:close_conn] == false + assert is_pid(opts[:conn]) + end + end +end diff --git a/test/http/request_builder_test.exs b/test/http/request_builder_test.exs index 80ef25d7b..27ca651be 100644 --- a/test/http/request_builder_test.exs +++ b/test/http/request_builder_test.exs @@ -5,30 +5,32 @@ defmodule Pleroma.HTTP.RequestBuilderTest do use ExUnit.Case, async: true use Pleroma.Tests.Helpers + alias Pleroma.Config + alias Pleroma.HTTP.Request alias Pleroma.HTTP.RequestBuilder describe "headers/2" do clear_config([:http, :send_user_agent]) test "don't send pleroma user agent" do - assert RequestBuilder.headers(%{}, []) == %{headers: []} + assert RequestBuilder.headers(%Request{}, []) == %Request{headers: []} end test "send pleroma user agent" do - Pleroma.Config.put([:http, :send_user_agent], true) - Pleroma.Config.put([:http, :user_agent], :default) + Config.put([:http, :send_user_agent], true) + Config.put([:http, :user_agent], :default) - assert RequestBuilder.headers(%{}, []) == %{ - headers: [{"User-Agent", Pleroma.Application.user_agent()}] + assert RequestBuilder.headers(%Request{}, []) == %Request{ + headers: [{"user-agent", Pleroma.Application.user_agent()}] } end test "send custom user agent" do - Pleroma.Config.put([:http, :send_user_agent], true) - Pleroma.Config.put([:http, :user_agent], "totally-not-pleroma") + Config.put([:http, :send_user_agent], true) + Config.put([:http, :user_agent], "totally-not-pleroma") - assert RequestBuilder.headers(%{}, []) == %{ - headers: [{"User-Agent", "totally-not-pleroma"}] + assert RequestBuilder.headers(%Request{}, []) == %Request{ + headers: [{"user-agent", "totally-not-pleroma"}] } end end @@ -40,19 +42,19 @@ test "don't add if keyword is empty" do test "add query parameter" do assert RequestBuilder.add_optional_params( - %{}, + %Request{}, %{query: :query, body: :body, another: :val}, [ {:query, "param1=val1¶m2=val2"}, {:body, "some body"} ] - ) == %{query: "param1=val1¶m2=val2", body: "some body"} + ) == %Request{query: "param1=val1¶m2=val2", body: "some body"} end end describe "add_param/4" do test "add file parameter" do - %{ + %Request{ body: %Tesla.Multipart{ boundary: _, content_type_params: [], @@ -69,7 +71,7 @@ test "add file parameter" do } ] } - } = RequestBuilder.add_param(%{}, :file, "filename.png", "some-path/filename.png") + } = RequestBuilder.add_param(%Request{}, :file, "filename.png", "some-path/filename.png") end test "add key to body" do @@ -81,7 +83,7 @@ test "add key to body" do %Tesla.Multipart.Part{ body: "\"someval\"", dispositions: [name: "somekey"], - headers: ["Content-Type": "application/json"] + headers: [{"content-type", "application/json"}] } ] } diff --git a/test/http_test.exs b/test/http_test.exs index 5f9522cf0..d80b96496 100644 --- a/test/http_test.exs +++ b/test/http_test.exs @@ -3,8 +3,10 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.HTTPTest do - use Pleroma.DataCase + use ExUnit.Case + use Pleroma.Tests.Helpers import Tesla.Mock + alias Pleroma.HTTP setup do mock(fn @@ -27,7 +29,7 @@ defmodule Pleroma.HTTPTest do describe "get/1" do test "returns successfully result" do - assert Pleroma.HTTP.get("http://example.com/hello") == { + assert HTTP.get("http://example.com/hello") == { :ok, %Tesla.Env{status: 200, body: "hello"} } @@ -36,7 +38,7 @@ test "returns successfully result" do describe "get/2 (with headers)" do test "returns successfully result for json content-type" do - assert Pleroma.HTTP.get("http://example.com/hello", [{"content-type", "application/json"}]) == + assert HTTP.get("http://example.com/hello", [{"content-type", "application/json"}]) == { :ok, %Tesla.Env{ @@ -50,10 +52,35 @@ test "returns successfully result for json content-type" do describe "post/2" do test "returns successfully result" do - assert Pleroma.HTTP.post("http://example.com/world", "") == { + assert HTTP.post("http://example.com/world", "") == { :ok, %Tesla.Env{status: 200, body: "world"} } end end + + describe "connection pools" do + @describetag :integration + clear_config([Pleroma.Gun.API]) do + Pleroma.Config.put([Pleroma.Gun.API], Pleroma.Gun) + end + + test "gun" do + adapter = Application.get_env(:tesla, :adapter) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Gun) + + on_exit(fn -> + Application.put_env(:tesla, :adapter, adapter) + end) + + options = [adapter: [pool: :federation]] + + assert {:ok, resp} = HTTP.get("https://httpbin.org/user-agent", [], options) + + assert resp.status == 200 + + state = Pleroma.Pool.Connections.get_state(:gun_connections) + assert state.conns["https:httpbin.org:443"] + end + end end diff --git a/test/notification_test.exs b/test/notification_test.exs index 04bf5b41a..1de3c6e3b 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -649,6 +649,13 @@ test "notifications are deleted if a remote user is deleted" do "object" => remote_user.ap_id } + remote_user_url = remote_user.ap_id + + Tesla.Mock.mock(fn + %{method: :get, url: ^remote_user_url} -> + %Tesla.Env{status: 404, body: ""} + end) + {:ok, _delete_activity} = Transmogrifier.handle_incoming(delete_user_message) ObanHelpers.perform_all() diff --git a/test/otp_version_test.exs b/test/otp_version_test.exs new file mode 100644 index 000000000..f26b90f61 --- /dev/null +++ b/test/otp_version_test.exs @@ -0,0 +1,58 @@ +defmodule Pleroma.OTPVersionTest do + use ExUnit.Case, async: true + + alias Pleroma.OTPVersion + + describe "get_and_check_version/2" do + test "22.4" do + assert OTPVersion.get_and_check_version(Tesla.Adapter.Gun, [ + "test/fixtures/warnings/otp_version/22.4" + ]) == :ok + end + + test "22.1" do + assert OTPVersion.get_and_check_version(Tesla.Adapter.Gun, [ + "test/fixtures/warnings/otp_version/22.1" + ]) == {:error, "22.1"} + end + + test "21.1" do + assert OTPVersion.get_and_check_version(Tesla.Adapter.Gun, [ + "test/fixtures/warnings/otp_version/21.1" + ]) == {:error, "21.1"} + end + + test "23.0" do + assert OTPVersion.get_and_check_version(Tesla.Adapter.Gun, [ + "test/fixtures/warnings/otp_version/23.0" + ]) == :ok + end + + test "undefined" do + assert OTPVersion.get_and_check_version(Tesla.Adapter.Gun, [ + "test/fixtures/warnings/otp_version/undefined" + ]) == :undefined + end + + test "not parsable" do + assert OTPVersion.get_and_check_version(Tesla.Adapter.Gun, [ + "test/fixtures/warnings/otp_version/error" + ]) == :undefined + end + + test "with non existance file" do + assert OTPVersion.get_and_check_version(Tesla.Adapter.Gun, [ + "test/fixtures/warnings/otp_version/non-exising", + "test/fixtures/warnings/otp_version/22.4" + ]) == :ok + end + + test "empty paths" do + assert OTPVersion.get_and_check_version(Tesla.Adapter.Gun, []) == :undefined + end + + test "another adapter" do + assert OTPVersion.get_and_check_version(Tesla.Adapter.Hackney, []) == :ok + end + end +end diff --git a/test/pool/connections_test.exs b/test/pool/connections_test.exs new file mode 100644 index 000000000..6f0e041ae --- /dev/null +++ b/test/pool/connections_test.exs @@ -0,0 +1,959 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Pool.ConnectionsTest do + use ExUnit.Case + use Pleroma.Tests.Helpers + import ExUnit.CaptureLog + alias Pleroma.Gun.API + alias Pleroma.Gun.Conn + alias Pleroma.Pool.Connections + + setup_all do + {:ok, _} = Registry.start_link(keys: :unique, name: API.Mock) + :ok + end + + setup do + name = :test_connections + adapter = Application.get_env(:tesla, :adapter) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Gun) + on_exit(fn -> Application.put_env(:tesla, :adapter, adapter) end) + + {:ok, _pid} = + Connections.start_link({name, [max_connections: 2, receive_connection_timeout: 1_500]}) + + {:ok, name: name} + end + + describe "alive?/2" do + test "is alive", %{name: name} do + assert Connections.alive?(name) + end + + test "returns false if not started" do + refute Connections.alive?(:some_random_name) + end + end + + test "opens connection and reuse it on next request", %{name: name} do + url = "http://some-domain.com" + key = "http:some-domain.com:80" + refute Connections.checkin(url, name) + :ok = Connections.open_conn(url, name) + + conn = Connections.checkin(url, name) + assert is_pid(conn) + assert Process.alive?(conn) + + self = self() + + %Connections{ + conns: %{ + ^key => %Conn{ + conn: ^conn, + gun_state: :up, + used_by: [{^self, _}], + conn_state: :active + } + } + } = Connections.get_state(name) + + reused_conn = Connections.checkin(url, name) + + assert conn == reused_conn + + %Connections{ + conns: %{ + ^key => %Conn{ + conn: ^conn, + gun_state: :up, + used_by: [{^self, _}, {^self, _}], + conn_state: :active + } + } + } = Connections.get_state(name) + + :ok = Connections.checkout(conn, self, name) + + %Connections{ + conns: %{ + ^key => %Conn{ + conn: ^conn, + gun_state: :up, + used_by: [{^self, _}], + conn_state: :active + } + } + } = Connections.get_state(name) + + :ok = Connections.checkout(conn, self, name) + + %Connections{ + conns: %{ + ^key => %Conn{ + conn: ^conn, + gun_state: :up, + used_by: [], + conn_state: :idle + } + } + } = Connections.get_state(name) + end + + test "reuse connection for idna domains", %{name: name} do + url = "http://ですsome-domain.com" + refute Connections.checkin(url, name) + + :ok = Connections.open_conn(url, name) + + conn = Connections.checkin(url, name) + assert is_pid(conn) + assert Process.alive?(conn) + + self = self() + + %Connections{ + conns: %{ + "http:ですsome-domain.com:80" => %Conn{ + conn: ^conn, + gun_state: :up, + used_by: [{^self, _}], + conn_state: :active + } + } + } = Connections.get_state(name) + + reused_conn = Connections.checkin(url, name) + + assert conn == reused_conn + end + + test "reuse for ipv4", %{name: name} do + url = "http://127.0.0.1" + + refute Connections.checkin(url, name) + + :ok = Connections.open_conn(url, name) + + conn = Connections.checkin(url, name) + assert is_pid(conn) + assert Process.alive?(conn) + + self = self() + + %Connections{ + conns: %{ + "http:127.0.0.1:80" => %Conn{ + conn: ^conn, + gun_state: :up, + used_by: [{^self, _}], + conn_state: :active + } + } + } = Connections.get_state(name) + + reused_conn = Connections.checkin(url, name) + + assert conn == reused_conn + + :ok = Connections.checkout(conn, self, name) + :ok = Connections.checkout(reused_conn, self, name) + + %Connections{ + conns: %{ + "http:127.0.0.1:80" => %Conn{ + conn: ^conn, + gun_state: :up, + used_by: [], + conn_state: :idle + } + } + } = Connections.get_state(name) + end + + test "reuse for ipv6", %{name: name} do + url = "http://[2a03:2880:f10c:83:face:b00c:0:25de]" + + refute Connections.checkin(url, name) + + :ok = Connections.open_conn(url, name) + + conn = Connections.checkin(url, name) + assert is_pid(conn) + assert Process.alive?(conn) + + self = self() + + %Connections{ + conns: %{ + "http:2a03:2880:f10c:83:face:b00c:0:25de:80" => %Conn{ + conn: ^conn, + gun_state: :up, + used_by: [{^self, _}], + conn_state: :active + } + } + } = Connections.get_state(name) + + reused_conn = Connections.checkin(url, name) + + assert conn == reused_conn + end + + test "up and down ipv4", %{name: name} do + self = self() + url = "http://127.0.0.1" + :ok = Connections.open_conn(url, name) + conn = Connections.checkin(url, name) + send(name, {:gun_down, conn, nil, nil, nil}) + send(name, {:gun_up, conn, nil}) + + %Connections{ + conns: %{ + "http:127.0.0.1:80" => %Conn{ + conn: ^conn, + gun_state: :up, + used_by: [{^self, _}], + conn_state: :active + } + } + } = Connections.get_state(name) + end + + test "up and down ipv6", %{name: name} do + self = self() + url = "http://[2a03:2880:f10c:83:face:b00c:0:25de]" + :ok = Connections.open_conn(url, name) + conn = Connections.checkin(url, name) + send(name, {:gun_down, conn, nil, nil, nil}) + send(name, {:gun_up, conn, nil}) + + %Connections{ + conns: %{ + "http:2a03:2880:f10c:83:face:b00c:0:25de:80" => %Conn{ + conn: ^conn, + gun_state: :up, + used_by: [{^self, _}], + conn_state: :active + } + } + } = Connections.get_state(name) + end + + test "reuses connection based on protocol", %{name: name} do + http_url = "http://some-domain.com" + http_key = "http:some-domain.com:80" + https_url = "https://some-domain.com" + https_key = "https:some-domain.com:443" + + refute Connections.checkin(http_url, name) + :ok = Connections.open_conn(http_url, name) + conn = Connections.checkin(http_url, name) + assert is_pid(conn) + assert Process.alive?(conn) + + refute Connections.checkin(https_url, name) + :ok = Connections.open_conn(https_url, name) + https_conn = Connections.checkin(https_url, name) + + refute conn == https_conn + + reused_https = Connections.checkin(https_url, name) + + refute conn == reused_https + + assert reused_https == https_conn + + %Connections{ + conns: %{ + ^http_key => %Conn{ + conn: ^conn, + gun_state: :up + }, + ^https_key => %Conn{ + conn: ^https_conn, + gun_state: :up + } + } + } = Connections.get_state(name) + end + + test "connection can't get up", %{name: name} do + url = "http://gun-not-up.com" + + assert capture_log(fn -> + :ok = Connections.open_conn(url, name) + refute Connections.checkin(url, name) + end) =~ + "Received error on opening connection http://gun-not-up.com: {:error, :timeout}" + end + + test "process gun_down message and then gun_up", %{name: name} do + self = self() + url = "http://gun-down-and-up.com" + key = "http:gun-down-and-up.com:80" + :ok = Connections.open_conn(url, name) + conn = Connections.checkin(url, name) + + assert is_pid(conn) + assert Process.alive?(conn) + + %Connections{ + conns: %{ + ^key => %Conn{ + conn: ^conn, + gun_state: :up, + used_by: [{^self, _}] + } + } + } = Connections.get_state(name) + + send(name, {:gun_down, conn, :http, nil, nil}) + + %Connections{ + conns: %{ + ^key => %Conn{ + conn: ^conn, + gun_state: :down, + used_by: [{^self, _}] + } + } + } = Connections.get_state(name) + + send(name, {:gun_up, conn, :http}) + + conn2 = Connections.checkin(url, name) + assert conn == conn2 + + assert is_pid(conn2) + assert Process.alive?(conn2) + + %Connections{ + conns: %{ + ^key => %Conn{ + conn: _, + gun_state: :up, + used_by: [{^self, _}, {^self, _}] + } + } + } = Connections.get_state(name) + end + + test "async processes get same conn for same domain", %{name: name} do + url = "http://some-domain.com" + :ok = Connections.open_conn(url, name) + + tasks = + for _ <- 1..5 do + Task.async(fn -> + Connections.checkin(url, name) + end) + end + + tasks_with_results = Task.yield_many(tasks) + + results = + Enum.map(tasks_with_results, fn {task, res} -> + res || Task.shutdown(task, :brutal_kill) + end) + + conns = for {:ok, value} <- results, do: value + + %Connections{ + conns: %{ + "http:some-domain.com:80" => %Conn{ + conn: conn, + gun_state: :up + } + } + } = Connections.get_state(name) + + assert Enum.all?(conns, fn res -> res == conn end) + end + + test "remove frequently used and idle", %{name: name} do + self = self() + http_url = "http://some-domain.com" + https_url = "https://some-domain.com" + :ok = Connections.open_conn(https_url, name) + :ok = Connections.open_conn(http_url, name) + + conn1 = Connections.checkin(https_url, name) + + [conn2 | _conns] = + for _ <- 1..4 do + Connections.checkin(http_url, name) + end + + http_key = "http:some-domain.com:80" + + %Connections{ + conns: %{ + ^http_key => %Conn{ + conn: ^conn2, + gun_state: :up, + conn_state: :active, + used_by: [{^self, _}, {^self, _}, {^self, _}, {^self, _}] + }, + "https:some-domain.com:443" => %Conn{ + conn: ^conn1, + gun_state: :up, + conn_state: :active, + used_by: [{^self, _}] + } + } + } = Connections.get_state(name) + + :ok = Connections.checkout(conn1, self, name) + + another_url = "http://another-domain.com" + :ok = Connections.open_conn(another_url, name) + conn = Connections.checkin(another_url, name) + + %Connections{ + conns: %{ + "http:another-domain.com:80" => %Conn{ + conn: ^conn, + gun_state: :up + }, + ^http_key => %Conn{ + conn: _, + gun_state: :up + } + } + } = Connections.get_state(name) + end + + describe "integration test" do + @describetag :integration + + clear_config([API]) do + Pleroma.Config.put([API], Pleroma.Gun) + end + + test "opens connection and reuse it on next request", %{name: name} do + url = "http://httpbin.org" + :ok = Connections.open_conn(url, name) + Process.sleep(250) + conn = Connections.checkin(url, name) + + assert is_pid(conn) + assert Process.alive?(conn) + + reused_conn = Connections.checkin(url, name) + + assert conn == reused_conn + + %Connections{ + conns: %{ + "http:httpbin.org:80" => %Conn{ + conn: ^conn, + gun_state: :up + } + } + } = Connections.get_state(name) + end + + test "opens ssl connection and reuse it on next request", %{name: name} do + url = "https://httpbin.org" + :ok = Connections.open_conn(url, name) + Process.sleep(1_000) + conn = Connections.checkin(url, name) + + assert is_pid(conn) + assert Process.alive?(conn) + + reused_conn = Connections.checkin(url, name) + + assert conn == reused_conn + + %Connections{ + conns: %{ + "https:httpbin.org:443" => %Conn{ + conn: ^conn, + gun_state: :up + } + } + } = Connections.get_state(name) + end + + test "remove frequently used and idle", %{name: name} do + self = self() + https1 = "https://www.google.com" + https2 = "https://httpbin.org" + + :ok = Connections.open_conn(https1, name) + :ok = Connections.open_conn(https2, name) + Process.sleep(1_500) + conn = Connections.checkin(https1, name) + + for _ <- 1..4 do + Connections.checkin(https2, name) + end + + %Connections{ + conns: %{ + "https:httpbin.org:443" => %Conn{ + conn: _, + gun_state: :up + }, + "https:www.google.com:443" => %Conn{ + conn: _, + gun_state: :up + } + } + } = Connections.get_state(name) + + :ok = Connections.checkout(conn, self, name) + http = "http://httpbin.org" + Process.sleep(1_000) + :ok = Connections.open_conn(http, name) + conn = Connections.checkin(http, name) + + %Connections{ + conns: %{ + "http:httpbin.org:80" => %Conn{ + conn: ^conn, + gun_state: :up + }, + "https:httpbin.org:443" => %Conn{ + conn: _, + gun_state: :up + } + } + } = Connections.get_state(name) + end + + test "remove earlier used and idle", %{name: name} do + self = self() + + https1 = "https://www.google.com" + https2 = "https://httpbin.org" + :ok = Connections.open_conn(https1, name) + :ok = Connections.open_conn(https2, name) + Process.sleep(1_500) + + Connections.checkin(https1, name) + conn = Connections.checkin(https1, name) + + Process.sleep(1_000) + Connections.checkin(https2, name) + Connections.checkin(https2, name) + + %Connections{ + conns: %{ + "https:httpbin.org:443" => %Conn{ + conn: _, + gun_state: :up + }, + "https:www.google.com:443" => %Conn{ + conn: ^conn, + gun_state: :up + } + } + } = Connections.get_state(name) + + :ok = Connections.checkout(conn, self, name) + :ok = Connections.checkout(conn, self, name) + + http = "http://httpbin.org" + :ok = Connections.open_conn(http, name) + Process.sleep(1_000) + + conn = Connections.checkin(http, name) + + %Connections{ + conns: %{ + "http:httpbin.org:80" => %Conn{ + conn: ^conn, + gun_state: :up + }, + "https:httpbin.org:443" => %Conn{ + conn: _, + gun_state: :up + } + } + } = Connections.get_state(name) + end + + test "doesn't open new conn on pool overflow", %{name: name} do + self = self() + + https1 = "https://www.google.com" + https2 = "https://httpbin.org" + :ok = Connections.open_conn(https1, name) + :ok = Connections.open_conn(https2, name) + Process.sleep(1_000) + Connections.checkin(https1, name) + conn1 = Connections.checkin(https1, name) + conn2 = Connections.checkin(https2, name) + + %Connections{ + conns: %{ + "https:httpbin.org:443" => %Conn{ + conn: ^conn2, + gun_state: :up, + conn_state: :active, + used_by: [{^self, _}] + }, + "https:www.google.com:443" => %Conn{ + conn: ^conn1, + gun_state: :up, + conn_state: :active, + used_by: [{^self, _}, {^self, _}] + } + } + } = Connections.get_state(name) + + refute Connections.checkin("http://httpbin.org", name) + + %Connections{ + conns: %{ + "https:httpbin.org:443" => %Conn{ + conn: ^conn2, + gun_state: :up, + conn_state: :active, + used_by: [{^self, _}] + }, + "https:www.google.com:443" => %Conn{ + conn: ^conn1, + gun_state: :up, + conn_state: :active, + used_by: [{^self, _}, {^self, _}] + } + } + } = Connections.get_state(name) + end + + test "get idle connection with the smallest crf", %{ + name: name + } do + self = self() + + https1 = "https://www.google.com" + https2 = "https://httpbin.org" + + :ok = Connections.open_conn(https1, name) + :ok = Connections.open_conn(https2, name) + Process.sleep(1_500) + Connections.checkin(https1, name) + Connections.checkin(https2, name) + Connections.checkin(https1, name) + conn1 = Connections.checkin(https1, name) + conn2 = Connections.checkin(https2, name) + + %Connections{ + conns: %{ + "https:httpbin.org:443" => %Conn{ + conn: ^conn2, + gun_state: :up, + conn_state: :active, + used_by: [{^self, _}, {^self, _}], + crf: crf2 + }, + "https:www.google.com:443" => %Conn{ + conn: ^conn1, + gun_state: :up, + conn_state: :active, + used_by: [{^self, _}, {^self, _}, {^self, _}], + crf: crf1 + } + } + } = Connections.get_state(name) + + assert crf1 > crf2 + + :ok = Connections.checkout(conn1, self, name) + :ok = Connections.checkout(conn1, self, name) + :ok = Connections.checkout(conn1, self, name) + + :ok = Connections.checkout(conn2, self, name) + :ok = Connections.checkout(conn2, self, name) + + %Connections{ + conns: %{ + "https:httpbin.org:443" => %Conn{ + conn: ^conn2, + gun_state: :up, + conn_state: :idle, + used_by: [] + }, + "https:www.google.com:443" => %Conn{ + conn: ^conn1, + gun_state: :up, + conn_state: :idle, + used_by: [] + } + } + } = Connections.get_state(name) + + http = "http://httpbin.org" + :ok = Connections.open_conn(http, name) + Process.sleep(1_000) + conn = Connections.checkin(http, name) + + %Connections{ + conns: %{ + "https:www.google.com:443" => %Conn{ + conn: ^conn1, + gun_state: :up, + conn_state: :idle, + used_by: [], + crf: crf1 + }, + "http:httpbin.org:80" => %Conn{ + conn: ^conn, + gun_state: :up, + conn_state: :active, + used_by: [{^self, _}], + crf: crf + } + } + } = Connections.get_state(name) + + assert crf1 > crf + end + end + + describe "with proxy" do + test "as ip", %{name: name} do + url = "http://proxy-string.com" + key = "http:proxy-string.com:80" + :ok = Connections.open_conn(url, name, proxy: {{127, 0, 0, 1}, 8123}) + + conn = Connections.checkin(url, name) + + %Connections{ + conns: %{ + ^key => %Conn{ + conn: ^conn, + gun_state: :up + } + } + } = Connections.get_state(name) + + reused_conn = Connections.checkin(url, name) + + assert reused_conn == conn + end + + test "as host", %{name: name} do + url = "http://proxy-tuple-atom.com" + :ok = Connections.open_conn(url, name, proxy: {'localhost', 9050}) + conn = Connections.checkin(url, name) + + %Connections{ + conns: %{ + "http:proxy-tuple-atom.com:80" => %Conn{ + conn: ^conn, + gun_state: :up + } + } + } = Connections.get_state(name) + + reused_conn = Connections.checkin(url, name) + + assert reused_conn == conn + end + + test "as ip and ssl", %{name: name} do + url = "https://proxy-string.com" + + :ok = Connections.open_conn(url, name, proxy: {{127, 0, 0, 1}, 8123}) + conn = Connections.checkin(url, name) + + %Connections{ + conns: %{ + "https:proxy-string.com:443" => %Conn{ + conn: ^conn, + gun_state: :up + } + } + } = Connections.get_state(name) + + reused_conn = Connections.checkin(url, name) + + assert reused_conn == conn + end + + test "as host and ssl", %{name: name} do + url = "https://proxy-tuple-atom.com" + :ok = Connections.open_conn(url, name, proxy: {'localhost', 9050}) + conn = Connections.checkin(url, name) + + %Connections{ + conns: %{ + "https:proxy-tuple-atom.com:443" => %Conn{ + conn: ^conn, + gun_state: :up + } + } + } = Connections.get_state(name) + + reused_conn = Connections.checkin(url, name) + + assert reused_conn == conn + end + + test "with socks type", %{name: name} do + url = "http://proxy-socks.com" + + :ok = Connections.open_conn(url, name, proxy: {:socks5, 'localhost', 1234}) + + conn = Connections.checkin(url, name) + + %Connections{ + conns: %{ + "http:proxy-socks.com:80" => %Conn{ + conn: ^conn, + gun_state: :up + } + } + } = Connections.get_state(name) + + reused_conn = Connections.checkin(url, name) + + assert reused_conn == conn + end + + test "with socks4 type and ssl", %{name: name} do + url = "https://proxy-socks.com" + + :ok = Connections.open_conn(url, name, proxy: {:socks4, 'localhost', 1234}) + + conn = Connections.checkin(url, name) + + %Connections{ + conns: %{ + "https:proxy-socks.com:443" => %Conn{ + conn: ^conn, + gun_state: :up + } + } + } = Connections.get_state(name) + + reused_conn = Connections.checkin(url, name) + + assert reused_conn == conn + end + end + + describe "crf/3" do + setup do + crf = Connections.crf(1, 10, 1) + {:ok, crf: crf} + end + + test "more used will have crf higher", %{crf: crf} do + # used 3 times + crf1 = Connections.crf(1, 10, crf) + crf1 = Connections.crf(1, 10, crf1) + + # used 2 times + crf2 = Connections.crf(1, 10, crf) + + assert crf1 > crf2 + end + + test "recently used will have crf higher on equal references", %{crf: crf} do + # used 3 sec ago + crf1 = Connections.crf(3, 10, crf) + + # used 4 sec ago + crf2 = Connections.crf(4, 10, crf) + + assert crf1 > crf2 + end + + test "equal crf on equal reference and time", %{crf: crf} do + # used 2 times + crf1 = Connections.crf(1, 10, crf) + + # used 2 times + crf2 = Connections.crf(1, 10, crf) + + assert crf1 == crf2 + end + + test "recently used will have higher crf", %{crf: crf} do + crf1 = Connections.crf(2, 10, crf) + crf1 = Connections.crf(1, 10, crf1) + + crf2 = Connections.crf(3, 10, crf) + crf2 = Connections.crf(4, 10, crf2) + assert crf1 > crf2 + end + end + + describe "get_unused_conns/1" do + test "crf is equalent, sorting by reference" do + conns = %{ + "1" => %Conn{ + conn_state: :idle, + last_reference: now() - 1 + }, + "2" => %Conn{ + conn_state: :idle, + last_reference: now() + } + } + + assert [{"1", _unused_conn} | _others] = Connections.get_unused_conns(conns) + end + + test "reference is equalent, sorting by crf" do + conns = %{ + "1" => %Conn{ + conn_state: :idle, + crf: 1.999 + }, + "2" => %Conn{ + conn_state: :idle, + crf: 2 + } + } + + assert [{"1", _unused_conn} | _others] = Connections.get_unused_conns(conns) + end + + test "higher crf and lower reference" do + conns = %{ + "1" => %Conn{ + conn_state: :idle, + crf: 3, + last_reference: now() - 1 + }, + "2" => %Conn{ + conn_state: :idle, + crf: 2, + last_reference: now() + } + } + + assert [{"2", _unused_conn} | _others] = Connections.get_unused_conns(conns) + end + + test "lower crf and lower reference" do + conns = %{ + "1" => %Conn{ + conn_state: :idle, + crf: 1.99, + last_reference: now() - 1 + }, + "2" => %Conn{ + conn_state: :idle, + crf: 2, + last_reference: now() + } + } + + assert [{"1", _unused_conn} | _others] = Connections.get_unused_conns(conns) + end + end + + defp now do + :os.system_time(:second) + end +end diff --git a/test/reverse_proxy/client/tesla_test.exs b/test/reverse_proxy/client/tesla_test.exs new file mode 100644 index 000000000..75a70988c --- /dev/null +++ b/test/reverse_proxy/client/tesla_test.exs @@ -0,0 +1,93 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ReverseProxy.Client.TeslaTest do + use ExUnit.Case + use Pleroma.Tests.Helpers + alias Pleroma.ReverseProxy.Client + @moduletag :integration + + clear_config_all([Pleroma.Gun.API]) do + Pleroma.Config.put([Pleroma.Gun.API], Pleroma.Gun) + end + + setup do + Application.put_env(:tesla, :adapter, Tesla.Adapter.Gun) + + on_exit(fn -> + Application.put_env(:tesla, :adapter, Tesla.Mock) + end) + end + + test "get response body stream" do + {:ok, status, headers, ref} = + Client.Tesla.request( + :get, + "http://httpbin.org/stream-bytes/10", + [{"accept", "application/octet-stream"}], + "", + [] + ) + + assert status == 200 + assert headers != [] + + {:ok, response, ref} = Client.Tesla.stream_body(ref) + check_ref(ref) + assert is_binary(response) + assert byte_size(response) == 10 + + assert :done == Client.Tesla.stream_body(ref) + assert :ok = Client.Tesla.close(ref) + end + + test "head response" do + {:ok, status, headers} = Client.Tesla.request(:head, "https://httpbin.org/get", [], "") + + assert status == 200 + assert headers != [] + end + + test "get error response" do + {:ok, status, headers, _body} = + Client.Tesla.request( + :get, + "https://httpbin.org/status/500", + [], + "" + ) + + assert status == 500 + assert headers != [] + end + + describe "client error" do + setup do + adapter = Application.get_env(:tesla, :adapter) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Hackney) + + on_exit(fn -> Application.put_env(:tesla, :adapter, adapter) end) + :ok + end + + test "adapter doesn't support reading body in chunks" do + assert_raise RuntimeError, + "Elixir.Tesla.Adapter.Hackney doesn't support reading body in chunks", + fn -> + Client.Tesla.request( + :get, + "http://httpbin.org/stream-bytes/10", + [{"accept", "application/octet-stream"}], + "" + ) + end + end + end + + defp check_ref(%{pid: pid, stream: stream} = ref) do + assert is_pid(pid) + assert is_reference(stream) + assert ref[:fin] + end +end diff --git a/test/reverse_proxy_test.exs b/test/reverse_proxy/reverse_proxy_test.exs similarity index 79% rename from test/reverse_proxy_test.exs rename to test/reverse_proxy/reverse_proxy_test.exs index 0672f57db..1ab3cc4bb 100644 --- a/test/reverse_proxy_test.exs +++ b/test/reverse_proxy/reverse_proxy_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.ReverseProxyTest do - use Pleroma.Web.ConnCase, async: true + use Pleroma.Web.ConnCase import ExUnit.CaptureLog import Mox alias Pleroma.ReverseProxy @@ -29,11 +29,11 @@ defp user_agent_mock(user_agent, invokes) do {"content-length", byte_size(json) |> to_string()} ], %{url: url}} end) - |> expect(:stream_body, invokes, fn %{url: url} -> + |> expect(:stream_body, invokes, fn %{url: url} = client -> case Registry.lookup(Pleroma.ReverseProxy.ClientMock, url) do [{_, 0}] -> Registry.update_value(Pleroma.ReverseProxy.ClientMock, url, &(&1 + 1)) - {:ok, json} + {:ok, json, client} [{_, 1}] -> Registry.unregister(Pleroma.ReverseProxy.ClientMock, url) @@ -78,7 +78,39 @@ test "closed connection", %{conn: conn} do assert conn.halted end - describe "max_body " do + defp stream_mock(invokes, with_close? \\ false) do + ClientMock + |> expect(:request, fn :get, "/stream-bytes/" <> length, _, _, _ -> + Registry.register(Pleroma.ReverseProxy.ClientMock, "/stream-bytes/" <> length, 0) + + {:ok, 200, [{"content-type", "application/octet-stream"}], + %{url: "/stream-bytes/" <> length}} + end) + |> expect(:stream_body, invokes, fn %{url: "/stream-bytes/" <> length} = client -> + max = String.to_integer(length) + + case Registry.lookup(Pleroma.ReverseProxy.ClientMock, "/stream-bytes/" <> length) do + [{_, current}] when current < max -> + Registry.update_value( + Pleroma.ReverseProxy.ClientMock, + "/stream-bytes/" <> length, + &(&1 + 10) + ) + + {:ok, "0123456789", client} + + [{_, ^max}] -> + Registry.unregister(Pleroma.ReverseProxy.ClientMock, "/stream-bytes/" <> length) + :done + end + end) + + if with_close? do + expect(ClientMock, :close, fn _ -> :ok end) + end + end + + describe "max_body" do test "length returns error if content-length more than option", %{conn: conn} do user_agent_mock("hackney/1.15.1", 0) @@ -94,38 +126,6 @@ test "length returns error if content-length more than option", %{conn: conn} do end) == "" end - defp stream_mock(invokes, with_close? \\ false) do - ClientMock - |> expect(:request, fn :get, "/stream-bytes/" <> length, _, _, _ -> - Registry.register(Pleroma.ReverseProxy.ClientMock, "/stream-bytes/" <> length, 0) - - {:ok, 200, [{"content-type", "application/octet-stream"}], - %{url: "/stream-bytes/" <> length}} - end) - |> expect(:stream_body, invokes, fn %{url: "/stream-bytes/" <> length} -> - max = String.to_integer(length) - - case Registry.lookup(Pleroma.ReverseProxy.ClientMock, "/stream-bytes/" <> length) do - [{_, current}] when current < max -> - Registry.update_value( - Pleroma.ReverseProxy.ClientMock, - "/stream-bytes/" <> length, - &(&1 + 10) - ) - - {:ok, "0123456789"} - - [{_, ^max}] -> - Registry.unregister(Pleroma.ReverseProxy.ClientMock, "/stream-bytes/" <> length) - :done - end - end) - - if with_close? do - expect(ClientMock, :close, fn _ -> :ok end) - end - end - test "max_body_length returns error if streaming body more than that option", %{conn: conn} do stream_mock(3, true) @@ -223,12 +223,12 @@ defp headers_mock(_) do Registry.register(Pleroma.ReverseProxy.ClientMock, "/headers", 0) {:ok, 200, [{"content-type", "application/json"}], %{url: "/headers", headers: headers}} end) - |> expect(:stream_body, 2, fn %{url: url, headers: headers} -> + |> expect(:stream_body, 2, fn %{url: url, headers: headers} = client -> case Registry.lookup(Pleroma.ReverseProxy.ClientMock, url) do [{_, 0}] -> Registry.update_value(Pleroma.ReverseProxy.ClientMock, url, &(&1 + 1)) headers = for {k, v} <- headers, into: %{}, do: {String.capitalize(k), v} - {:ok, Jason.encode!(%{headers: headers})} + {:ok, Jason.encode!(%{headers: headers}), client} [{_, 1}] -> Registry.unregister(Pleroma.ReverseProxy.ClientMock, url) @@ -305,11 +305,11 @@ defp disposition_headers_mock(headers) do {:ok, 200, headers, %{url: "/disposition"}} end) - |> expect(:stream_body, 2, fn %{url: "/disposition"} -> + |> expect(:stream_body, 2, fn %{url: "/disposition"} = client -> case Registry.lookup(Pleroma.ReverseProxy.ClientMock, "/disposition") do [{_, 0}] -> Registry.update_value(Pleroma.ReverseProxy.ClientMock, "/disposition", &(&1 + 1)) - {:ok, ""} + {:ok, "", client} [{_, 1}] -> Registry.unregister(Pleroma.ReverseProxy.ClientMock, "/disposition") @@ -341,4 +341,45 @@ test "with content-disposition header", %{conn: conn} do assert {"content-disposition", "attachment; filename=\"filename.jpg\""} in conn.resp_headers end end + + describe "tesla client using gun integration" do + @describetag :integration + + clear_config([Pleroma.ReverseProxy.Client]) do + Pleroma.Config.put([Pleroma.ReverseProxy.Client], Pleroma.ReverseProxy.Client.Tesla) + end + + clear_config([Pleroma.Gun.API]) do + Pleroma.Config.put([Pleroma.Gun.API], Pleroma.Gun) + end + + setup do + adapter = Application.get_env(:tesla, :adapter) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Gun) + + on_exit(fn -> + Application.put_env(:tesla, :adapter, adapter) + end) + end + + test "common", %{conn: conn} do + conn = ReverseProxy.call(conn, "http://httpbin.org/stream-bytes/10") + assert byte_size(conn.resp_body) == 10 + assert conn.state == :chunked + assert conn.status == 200 + end + + test "ssl", %{conn: conn} do + conn = ReverseProxy.call(conn, "https://httpbin.org/stream-bytes/10") + assert byte_size(conn.resp_body) == 10 + assert conn.state == :chunked + assert conn.status == 200 + end + + test "follow redirects", %{conn: conn} do + conn = ReverseProxy.call(conn, "https://httpbin.org/redirect/5") + assert conn.state == :chunked + assert conn.status == 200 + end + end end diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index ba3341327..5727871ea 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -107,7 +107,7 @@ def get( "https://osada.macgirvin.com/.well-known/webfinger?resource=acct:mike@osada.macgirvin.com", _, _, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ) do {:ok, %Tesla.Env{ @@ -120,7 +120,7 @@ def get( "https://social.heldscal.la/.well-known/webfinger?resource=https://social.heldscal.la/user/29191", _, _, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ) do {:ok, %Tesla.Env{ @@ -141,7 +141,7 @@ def get( "https://pawoo.net/.well-known/webfinger?resource=acct:https://pawoo.net/users/pekorino", _, _, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ) do {:ok, %Tesla.Env{ @@ -167,7 +167,7 @@ def get( "https://social.stopwatchingus-heidelberg.de/.well-known/webfinger?resource=acct:https://social.stopwatchingus-heidelberg.de/user/18330", _, _, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ) do {:ok, %Tesla.Env{ @@ -188,7 +188,7 @@ def get( "https://mamot.fr/.well-known/webfinger?resource=acct:https://mamot.fr/users/Skruyb", _, _, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ) do {:ok, %Tesla.Env{ @@ -201,7 +201,7 @@ def get( "https://social.heldscal.la/.well-known/webfinger?resource=nonexistant@social.heldscal.la", _, _, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ) do {:ok, %Tesla.Env{ @@ -214,7 +214,7 @@ def get( "https://squeet.me/xrd/?uri=lain@squeet.me", _, _, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ) do {:ok, %Tesla.Env{ @@ -227,7 +227,7 @@ def get( "https://mst3k.interlinked.me/users/luciferMysticus", _, _, - Accept: "application/activity+json" + [{"accept", "application/activity+json"}] ) do {:ok, %Tesla.Env{ @@ -248,7 +248,7 @@ def get( "https://hubzilla.example.org/channel/kaniini", _, _, - Accept: "application/activity+json" + [{"accept", "application/activity+json"}] ) do {:ok, %Tesla.Env{ @@ -257,7 +257,7 @@ def get( }} end - def get("https://niu.moe/users/rye", _, _, Accept: "application/activity+json") do + def get("https://niu.moe/users/rye", _, _, [{"accept", "application/activity+json"}]) do {:ok, %Tesla.Env{ status: 200, @@ -265,7 +265,7 @@ def get("https://niu.moe/users/rye", _, _, Accept: "application/activity+json") }} end - def get("https://n1u.moe/users/rye", _, _, Accept: "application/activity+json") do + def get("https://n1u.moe/users/rye", _, _, [{"accept", "application/activity+json"}]) do {:ok, %Tesla.Env{ status: 200, @@ -284,7 +284,7 @@ def get("http://mastodon.example.org/users/admin/statuses/100787282858396771", _ }} end - def get("https://puckipedia.com/", _, _, Accept: "application/activity+json") do + def get("https://puckipedia.com/", _, _, [{"accept", "application/activity+json"}]) do {:ok, %Tesla.Env{ status: 200, @@ -308,9 +308,9 @@ def get("https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3" }} end - def get("https://mobilizon.org/events/252d5816-00a3-4a89-a66f-15bf65c33e39", _, _, - Accept: "application/activity+json" - ) do + def get("https://mobilizon.org/events/252d5816-00a3-4a89-a66f-15bf65c33e39", _, _, [ + {"accept", "application/activity+json"} + ]) do {:ok, %Tesla.Env{ status: 200, @@ -318,7 +318,7 @@ def get("https://mobilizon.org/events/252d5816-00a3-4a89-a66f-15bf65c33e39", _, }} end - def get("https://mobilizon.org/@tcit", _, _, Accept: "application/activity+json") do + def get("https://mobilizon.org/@tcit", _, _, [{"accept", "application/activity+json"}]) do {:ok, %Tesla.Env{ status: 200, @@ -358,7 +358,7 @@ def get("https://wedistribute.org/wp-json/pterotype/v1/actor/-blog", _, _, _) do }} end - def get("http://mastodon.example.org/users/admin", _, _, Accept: "application/activity+json") do + def get("http://mastodon.example.org/users/admin", _, _, _) do {:ok, %Tesla.Env{ status: 200, @@ -366,7 +366,9 @@ def get("http://mastodon.example.org/users/admin", _, _, Accept: "application/ac }} end - def get("http://mastodon.example.org/users/relay", _, _, Accept: "application/activity+json") do + def get("http://mastodon.example.org/users/relay", _, _, [ + {"accept", "application/activity+json"} + ]) do {:ok, %Tesla.Env{ status: 200, @@ -374,7 +376,9 @@ def get("http://mastodon.example.org/users/relay", _, _, Accept: "application/ac }} end - def get("http://mastodon.example.org/users/gargron", _, _, Accept: "application/activity+json") do + def get("http://mastodon.example.org/users/gargron", _, _, [ + {"accept", "application/activity+json"} + ]) do {:error, :nxdomain} end @@ -557,7 +561,7 @@ def get( "http://mastodon.example.org/@admin/99541947525187367", _, _, - Accept: "application/activity+json" + _ ) do {:ok, %Tesla.Env{ @@ -582,7 +586,7 @@ def get("https://shitposter.club/notice/7369654", _, _, _) do }} end - def get("https://mstdn.io/users/mayuutann", _, _, Accept: "application/activity+json") do + def get("https://mstdn.io/users/mayuutann", _, _, [{"accept", "application/activity+json"}]) do {:ok, %Tesla.Env{ status: 200, @@ -594,7 +598,7 @@ def get( "https://mstdn.io/users/mayuutann/statuses/99568293732299394", _, _, - Accept: "application/activity+json" + [{"accept", "application/activity+json"}] ) do {:ok, %Tesla.Env{ @@ -614,7 +618,7 @@ def get("https://pleroma.soykaf.com/users/lain/feed.atom", _, _, _) do }} end - def get(url, _, _, Accept: "application/xrd+xml,application/jrd+json") + def get(url, _, _, [{"accept", "application/xrd+xml,application/jrd+json"}]) when url in [ "https://pleroma.soykaf.com/.well-known/webfinger?resource=acct:https://pleroma.soykaf.com/users/lain", "https://pleroma.soykaf.com/.well-known/webfinger?resource=https://pleroma.soykaf.com/users/lain" @@ -641,7 +645,7 @@ def get( "https://shitposter.club/.well-known/webfinger?resource=https://shitposter.club/user/1", _, _, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ) do {:ok, %Tesla.Env{ @@ -685,7 +689,7 @@ def get( "https://shitposter.club/.well-known/webfinger?resource=https://shitposter.club/user/5381", _, _, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ) do {:ok, %Tesla.Env{ @@ -738,7 +742,7 @@ def get( "https://social.sakamoto.gq/.well-known/webfinger?resource=https://social.sakamoto.gq/users/eal", _, _, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ) do {:ok, %Tesla.Env{ @@ -751,7 +755,7 @@ def get( "https://social.sakamoto.gq/objects/0ccc1a2c-66b0-4305-b23a-7f7f2b040056", _, _, - Accept: "application/atom+xml" + [{"accept", "application/atom+xml"}] ) do {:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/sakamoto.atom")}} end @@ -768,7 +772,7 @@ def get( "https://mastodon.social/.well-known/webfinger?resource=https://mastodon.social/users/lambadalambda", _, _, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ) do {:ok, %Tesla.Env{ @@ -790,7 +794,7 @@ def get( "http://gs.example.org/.well-known/webfinger?resource=http://gs.example.org:4040/index.php/user/1", _, _, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ) do {:ok, %Tesla.Env{ @@ -804,7 +808,7 @@ def get( "http://gs.example.org:4040/index.php/user/1", _, _, - Accept: "application/activity+json" + [{"accept", "application/activity+json"}] ) do {:ok, %Tesla.Env{status: 406, body: ""}} end @@ -840,7 +844,7 @@ def get( "https://squeet.me/xrd?uri=lain@squeet.me", _, _, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ) do {:ok, %Tesla.Env{ @@ -853,7 +857,7 @@ def get( "https://social.heldscal.la/.well-known/webfinger?resource=shp@social.heldscal.la", _, _, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ) do {:ok, %Tesla.Env{ @@ -866,7 +870,7 @@ def get( "https://social.heldscal.la/.well-known/webfinger?resource=invalid_content@social.heldscal.la", _, _, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ) do {:ok, %Tesla.Env{status: 200, body: ""}} end @@ -883,7 +887,7 @@ def get( "http://framatube.org/main/xrd?uri=framasoft@framatube.org", _, _, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ) do {:ok, %Tesla.Env{ @@ -905,7 +909,7 @@ def get( "http://gnusocial.de/main/xrd?uri=winterdienst@gnusocial.de", _, _, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ) do {:ok, %Tesla.Env{ @@ -942,7 +946,7 @@ def get( "https://gerzilla.de/xrd/?uri=kaniini@gerzilla.de", _, _, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ) do {:ok, %Tesla.Env{ @@ -1005,7 +1009,7 @@ def get("https://apfed.club/channel/indio", _, _, _) do %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/osada-user-indio.json")}} end - def get("https://social.heldscal.la/user/23211", _, _, Accept: "application/activity+json") do + def get("https://social.heldscal.la/user/23211", _, _, [{"accept", "application/activity+json"}]) do {:ok, Tesla.Mock.json(%{"id" => "https://social.heldscal.la/user/23211"}, status: 200)} end @@ -1138,7 +1142,7 @@ def get( "https://zetsubou.xn--q9jyb4c/.well-known/webfinger?resource=lain@zetsubou.xn--q9jyb4c", _, _, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ) do {:ok, %Tesla.Env{ @@ -1151,7 +1155,7 @@ def get( "https://zetsubou.xn--q9jyb4c/.well-known/webfinger?resource=https://zetsubou.xn--q9jyb4c/users/lain", _, _, - Accept: "application/xrd+xml,application/jrd+json" + [{"accept", "application/xrd+xml,application/jrd+json"}] ) do {:ok, %Tesla.Env{ @@ -1173,7 +1177,9 @@ def get( }} end - def get("https://info.pleroma.site/activity.json", _, _, Accept: "application/activity+json") do + def get("https://info.pleroma.site/activity.json", _, _, [ + {"accept", "application/activity+json"} + ]) do {:ok, %Tesla.Env{ status: 200, @@ -1185,7 +1191,9 @@ def get("https://info.pleroma.site/activity.json", _, _, _) do {:ok, %Tesla.Env{status: 404, body: ""}} end - def get("https://info.pleroma.site/activity2.json", _, _, Accept: "application/activity+json") do + def get("https://info.pleroma.site/activity2.json", _, _, [ + {"accept", "application/activity+json"} + ]) do {:ok, %Tesla.Env{ status: 200, @@ -1197,7 +1205,9 @@ def get("https://info.pleroma.site/activity2.json", _, _, _) do {:ok, %Tesla.Env{status: 404, body: ""}} end - def get("https://info.pleroma.site/activity3.json", _, _, Accept: "application/activity+json") do + def get("https://info.pleroma.site/activity3.json", _, _, [ + {"accept", "application/activity+json"} + ]) do {:ok, %Tesla.Env{ status: 200, diff --git a/test/user_invite_token_test.exs b/test/user_invite_token_test.exs index 111e40361..671560e41 100644 --- a/test/user_invite_token_test.exs +++ b/test/user_invite_token_test.exs @@ -4,7 +4,6 @@ defmodule Pleroma.UserInviteTokenTest do use ExUnit.Case, async: true - use Pleroma.DataCase alias Pleroma.UserInviteToken describe "valid_invite?/1 one time invites" do @@ -64,7 +63,6 @@ test "expires today returns true", %{invite: invite} do test "expires yesterday returns false", %{invite: invite} do invite = %{invite | expires_at: Date.add(Date.utc_today(), -1)} - invite = Repo.insert!(invite) refute UserInviteToken.valid_invite?(invite) end end @@ -82,7 +80,6 @@ test "not overdue date and less uses returns true", %{invite: invite} do test "overdue date and less uses returns false", %{invite: invite} do invite = %{invite | expires_at: Date.add(Date.utc_today(), -1)} - invite = Repo.insert!(invite) refute UserInviteToken.valid_invite?(invite) end @@ -93,7 +90,6 @@ test "not overdue date with more uses returns false", %{invite: invite} do test "overdue date with more uses returns false", %{invite: invite} do invite = %{invite | expires_at: Date.add(Date.utc_today(), -1), uses: 5} - invite = Repo.insert!(invite) refute UserInviteToken.valid_invite?(invite) end end diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 5fbdf96f6..02ffbfa0b 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -2439,7 +2439,8 @@ test "saving full setting if value is not keyword", %{conn: conn} do "value" => "Tesla.Adapter.Httpc", "db" => [":adapter"] } - ] + ], + "need_reboot" => true } end @@ -2526,7 +2527,6 @@ test "common config example", %{conn: conn} do %{"tuple" => [":seconds_valid", 60]}, %{"tuple" => [":path", ""]}, %{"tuple" => [":key1", nil]}, - %{"tuple" => [":partial_chain", "&:hackney_connect.partial_chain/1"]}, %{"tuple" => [":regex1", "~r/https:\/\/example.com/"]}, %{"tuple" => [":regex2", "~r/https:\/\/example.com/u"]}, %{"tuple" => [":regex3", "~r/https:\/\/example.com/i"]}, @@ -2556,7 +2556,6 @@ test "common config example", %{conn: conn} do %{"tuple" => [":seconds_valid", 60]}, %{"tuple" => [":path", ""]}, %{"tuple" => [":key1", nil]}, - %{"tuple" => [":partial_chain", "&:hackney_connect.partial_chain/1"]}, %{"tuple" => [":regex1", "~r/https:\\/\\/example.com/"]}, %{"tuple" => [":regex2", "~r/https:\\/\\/example.com/u"]}, %{"tuple" => [":regex3", "~r/https:\\/\\/example.com/i"]}, @@ -2569,7 +2568,6 @@ test "common config example", %{conn: conn} do ":seconds_valid", ":path", ":key1", - ":partial_chain", ":regex1", ":regex2", ":regex3", @@ -2583,7 +2581,8 @@ test "common config example", %{conn: conn} do "value" => "Tesla.Adapter.Httpc", "db" => [":adapter"] } - ] + ], + "need_reboot" => true } end diff --git a/test/web/common_api/common_api_utils_test.exs b/test/web/common_api/common_api_utils_test.exs index 848300ef3..759501a67 100644 --- a/test/web/common_api/common_api_utils_test.exs +++ b/test/web/common_api/common_api_utils_test.exs @@ -474,6 +474,13 @@ test "returns recipients when object not found" do activity = insert(:note_activity, user: user, note: object) Pleroma.Repo.delete(object) + obj_url = activity.data["object"] + + Tesla.Mock.mock(fn + %{method: :get, url: ^obj_url} -> + %Tesla.Env{status: 404, body: ""} + end) + assert Utils.maybe_notify_mentioned_recipients(["test-test"], activity) == [ "test-test" ] diff --git a/test/web/push/impl_test.exs b/test/web/push/impl_test.exs index acae7a734..737976f1f 100644 --- a/test/web/push/impl_test.exs +++ b/test/web/push/impl_test.exs @@ -126,7 +126,7 @@ test "renders title and body for follow activity" do user = insert(:user, nickname: "Bob") other_user = insert(:user) {:ok, _, _, activity} = CommonAPI.follow(user, other_user) - object = Object.normalize(activity) + object = Object.normalize(activity, false) assert Impl.format_body(%{activity: activity}, user, object) == "@Bob has followed you" From 2a219f5e86bea076b1bc93f1a9205c764d43a380 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 18 Feb 2020 09:12:46 -0600 Subject: [PATCH 016/188] Improve changelog message --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48080503a..e4bce5c02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,7 +73,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Support for custom Elixir modules (such as MRF policies) - User settings: Add _This account is a_ option. - OAuth: admin scopes support (relevant setting: `[:auth, :enforce_oauth_admin_scope_usage]`). -- New HTTP adapter [gun](https://github.com/ninenines/gun). Gun adapter requires OTP version older that 22.2, otherwise pleroma won’t start. For hackney OTP update is not required. +- New HTTP adapter [gun](https://github.com/ninenines/gun). Gun adapter requires minimum OTP version of 22.2 otherwise Pleroma won’t start. For hackney OTP update is not required.
API Changes From 7d73e7a09a72354acf526652e307149afbf5b1a3 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 18 Feb 2020 09:18:09 -0600 Subject: [PATCH 017/188] Spelling --- lib/pleroma/http/adapter/gun.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/http/adapter/gun.ex b/lib/pleroma/http/adapter/gun.ex index f25afeda7..ec6475e96 100644 --- a/lib/pleroma/http/adapter/gun.ex +++ b/lib/pleroma/http/adapter/gun.ex @@ -90,7 +90,7 @@ defp try_to_get_conn(uri, opts) do case Connections.checkin(uri, :gun_connections) do nil -> Logger.info( - "Gun connections pool checkin was not succesfull. Trying to open conn for next request." + "Gun connections pool checkin was not successful. Trying to open conn for next request." ) :ok = Connections.open_conn(uri, :gun_connections, opts) From 138a3c1fe48bbace79c0121d4571db3c2a827860 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 18 Feb 2020 09:30:18 -0600 Subject: [PATCH 018/188] Spelling was wrong in test as well --- test/http/adapter/gun_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/http/adapter/gun_test.exs b/test/http/adapter/gun_test.exs index 37489e1a4..1d7977c83 100644 --- a/test/http/adapter/gun_test.exs +++ b/test/http/adapter/gun_test.exs @@ -101,7 +101,7 @@ test "get conn on next request" do assert opts[:conn] == nil assert opts[:close_conn] == nil end) =~ - "Gun connections pool checkin was not succesfull. Trying to open conn for next request." + "Gun connections pool checkin was not successful. Trying to open conn for next request." opts = Gun.options(uri) From c9db0507f8d49aee9988b0b63477672f5df9c0b2 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 19 Feb 2020 12:19:03 +0300 Subject: [PATCH 019/188] removing retry option and changing some logger messages levels --- lib/pleroma/http/adapter/gun.ex | 28 +++++++++++++++++++++------- lib/pleroma/pool/connections.ex | 17 ++++++++--------- test/http/adapter/gun_test.exs | 2 +- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/lib/pleroma/http/adapter/gun.ex b/lib/pleroma/http/adapter/gun.ex index ec6475e96..f1018dd8d 100644 --- a/lib/pleroma/http/adapter/gun.ex +++ b/lib/pleroma/http/adapter/gun.ex @@ -15,7 +15,7 @@ defmodule Pleroma.HTTP.Adapter.Gun do connect_timeout: 20_000, domain_lookup_timeout: 5_000, tls_handshake_timeout: 5_000, - retry_timeout: 100, + retry: 0, await_up_timeout: 5_000 ] @@ -89,7 +89,7 @@ defp try_to_get_conn(uri, opts) do try do case Connections.checkin(uri, :gun_connections) do nil -> - Logger.info( + Logger.debug( "Gun connections pool checkin was not successful. Trying to open conn for next request." ) @@ -97,7 +97,9 @@ defp try_to_get_conn(uri, opts) do opts conn when is_pid(conn) -> - Logger.debug("received conn #{inspect(conn)} #{Connections.compose_uri(uri)}") + Logger.debug( + "received conn #{inspect(conn)} #{uri.scheme}://#{Connections.compose_uri(uri)}" + ) opts |> Keyword.put(:conn, conn) @@ -105,18 +107,30 @@ defp try_to_get_conn(uri, opts) do end rescue error -> - Logger.warn("Gun connections pool checkin caused error #{inspect(error)}") + Logger.warn( + "Gun connections pool checkin caused error #{uri.scheme}://#{ + Connections.compose_uri(uri) + } #{inspect(error)}" + ) + opts catch :exit, {:timeout, _} -> - Logger.info( - "Gun connections pool checkin with timeout error #{Connections.compose_uri(uri)}" + Logger.warn( + "Gun connections pool checkin with timeout error #{uri.scheme}://#{ + Connections.compose_uri(uri) + }" ) opts :exit, error -> - Logger.warn("Gun pool checkin exited with error #{inspect(error)}") + Logger.warn( + "Gun pool checkin exited with error #{uri.scheme}://#{Connections.compose_uri(uri)} #{ + inspect(error) + }" + ) + opts end end diff --git a/lib/pleroma/pool/connections.ex b/lib/pleroma/pool/connections.ex index 1ed16d1c1..c7136e0e0 100644 --- a/lib/pleroma/pool/connections.ex +++ b/lib/pleroma/pool/connections.ex @@ -52,8 +52,7 @@ def open_conn(%URI{} = uri, name, opts) do opts = opts |> Enum.into(%{}) - |> Map.put_new(:receive, false) - |> Map.put_new(:retry, pool_opts[:retry] || 5) + |> Map.put_new(:retry, pool_opts[:retry] || 0) |> Map.put_new(:retry_timeout, pool_opts[:retry_timeout] || 100) |> Map.put_new(:await_up_timeout, pool_opts[:await_up_timeout] || 5_000) @@ -108,11 +107,11 @@ def handle_cast({:checkout, conn_pid, pid}, state) do put_in(state.conns[key], %{conn | conn_state: conn_state, used_by: used_by}) else false -> - Logger.warn("checkout for closed conn #{inspect(conn_pid)}") + Logger.debug("checkout for closed conn #{inspect(conn_pid)}") state nil -> - Logger.info("checkout for alive conn #{inspect(conn_pid)}, but is not in state") + Logger.debug("checkout for alive conn #{inspect(conn_pid)}, but is not in state") state end @@ -172,15 +171,15 @@ def handle_info({:gun_up, conn_pid, _protocol}, state) do }) else :error_gun_info -> - Logger.warn(":gun.info caused error") + Logger.debug(":gun.info caused error") state false -> - Logger.warn(":gun_up message for closed conn #{inspect(conn_pid)}") + Logger.debug(":gun_up message for closed conn #{inspect(conn_pid)}") state nil -> - Logger.warn( + Logger.debug( ":gun_up message for alive conn #{inspect(conn_pid)}, but deleted from state" ) @@ -216,11 +215,11 @@ def handle_info({:gun_down, conn_pid, _protocol, _reason, _killed}, state) do else false -> # gun can send gun_down for closed conn, maybe connection is not closed yet - Logger.warn(":gun_down message for closed conn #{inspect(conn_pid)}") + Logger.debug(":gun_down message for closed conn #{inspect(conn_pid)}") state nil -> - Logger.warn( + Logger.debug( ":gun_down message for alive conn #{inspect(conn_pid)}, but deleted from state" ) diff --git a/test/http/adapter/gun_test.exs b/test/http/adapter/gun_test.exs index 1d7977c83..ef1b4a882 100644 --- a/test/http/adapter/gun_test.exs +++ b/test/http/adapter/gun_test.exs @@ -91,7 +91,7 @@ test "don't receive conn if receive_conn is false" do test "get conn on next request" do level = Application.get_env(:logger, :level) - Logger.configure(level: :info) + Logger.configure(level: :debug) on_exit(fn -> Logger.configure(level: level) end) uri = URI.parse("http://some-domain2.com") From 3849bbb60d9085bced717fef1f09216d570af287 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 21 Feb 2020 10:15:56 +0300 Subject: [PATCH 020/188] temp using tesla from fork --- mix.exs | 6 +++++- mix.lock | 46 +++++++++++++++++++++++----------------------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/mix.exs b/mix.exs index 273307bbe..18e33b214 100644 --- a/mix.exs +++ b/mix.exs @@ -119,7 +119,11 @@ defp deps do {:calendar, "~> 0.17.4"}, {:cachex, "~> 3.0.2"}, {:poison, "~> 3.0", override: true}, - {:tesla, "~> 1.3", override: true}, + # {:tesla, "~> 1.3", override: true}, + {:tesla, + github: "alex-strizhakov/tesla", + ref: "922cc3db13b421763edbea76246b8ea61c38c6fa", + override: true}, {:castore, "~> 0.1"}, {:cowlib, "~> 2.8", override: true}, {:gun, diff --git a/mix.lock b/mix.lock index 12ce1afac..10b2fe30d 100644 --- a/mix.lock +++ b/mix.lock @@ -21,42 +21,42 @@ "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"}, + "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"}, - "ecto": {:hex, :ecto, "3.3.3", "0830bf3aebcbf3d8c1a1811cd581773b6866886c012f52c0f027031fa96a0b53", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, + "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, + "ecto": {:hex, :ecto, "3.3.3", "0830bf3aebcbf3d8c1a1811cd581773b6866886c012f52c0f027031fa96a0b53", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "12e368e3c2a2938d7776defaabdae40e82900fc4d8d66120ec1e01dfd8b93c3a"}, "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"}, - "esshd": {:hex, :esshd, "0.1.1", "d4dd4c46698093a40a56afecce8a46e246eb35463c457c246dacba2e056f31b5", [:mix], [], "hexpm"}, + "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"}, + "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"}, - "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"}, + "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"}, + "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"}, "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"}, + "floki": {:hex, :floki, "0.25.0", "b1c9ddf5f32a3a90b43b76f3386ca054325dc2478af020e87b5111c19f2284ac", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "631f4e627c46d5ecd347df5a2accdaf0621c77c3693c5b75a8ad58e84c61f242"}, "gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"}, "gen_stage": {:hex, :gen_stage, "0.14.3", "d0c66f1c87faa301c1a85a809a3ee9097a4264b2edf7644bf5c123237ef732bf", [:mix], [], "hexpm"}, "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"}, "gettext": {:hex, :gettext, "0.17.4", "f13088e1ec10ce01665cf25f5ff779e7df3f2dc71b37084976cf89d1aa124d5c", [:mix], [], "hexpm", "3c75b5ea8288e2ee7ea503ff9e30dfe4d07ad3c054576a6e60040e79a801e14d"}, "gun": {:git, "https://github.com/ninenines/gun.git", "bd6425ab87428cf4c95f4d23e0a48fd065fbd714", [ref: "bd6425ab87428cf4c95f4d23e0a48fd065fbd714"]}, "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"}, + "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"}, + "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"}, "inet_cidr": {:hex, :inet_cidr, "1.0.4", "a05744ab7c221ca8e395c926c3919a821eb512e8f36547c062f62c4ca0cf3d6e", [:mix], [], "hexpm", "64a2d30189704ae41ca7dbdd587f5291db5d1dda1414e0774c29ffc81088c1bc"}, "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"}, - "joken": {:hex, :joken, "2.2.0", "2daa1b12be05184aff7b5ace1d43ca1f81345962285fff3f88db74927c954d3a", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm"}, - "jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm"}, + "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"}, "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"}, @@ -69,38 +69,38 @@ "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"}, + "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, "0.12.1", "695e9490c6e0edfca616d80639528e448bd29b3bff7b7dd10a56c79b00a5d7fb", [: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", "c1d58d69b8b5a86e7167abbb8cc92764a66f25f12f6172052595067fc6a30a17"}, "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"}, - "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"}, - "phoenix_html": {:hex, :phoenix_html, "2.14.0", "d8c6bc28acc8e65f8ea0080ee05aa13d912c8758699283b8d3427b655aabe284", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, + "phoenix": {:hex, :phoenix, "1.4.13", "67271ad69b51f3719354604f4a3f968f83aa61c19199343656c9caee057ff3b8", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ab765a0feddb81fc62e2116c827b5f068df85159c162bee760745276ad7ddc1b"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.1.0", "a044d0756d0464c5a541b4a0bf4bcaf89bffcaf92468862408290682c73ae50d", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c5e666a341ff104d0399d8f0e4ff094559b2fde13a5985d4cb5023b2c2ac558b"}, + "phoenix_html": {:hex, :phoenix_html, "2.14.0", "d8c6bc28acc8e65f8ea0080ee05aa13d912c8758699283b8d3427b655aabe284", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b0bb30eda478a06dbfbe96728061a93833db3861a49ccb516f839ecb08493fbb"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm", "1f13f9f0f3e769a667a6b6828d29dec37497a082d195cc52dbef401a9b69bf38"}, "phoenix_swoosh": {:hex, :phoenix_swoosh, "0.2.0", "a7e0b32077cd6d2323ae15198839b05d9caddfa20663fd85787479e81f89520e", [:mix], [{:phoenix, "~> 1.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:swoosh, "~> 0.1", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "ebf1bfa7b3c1c850c04929afe02e2e0d7ab135e0706332c865de03e761676b1f"}, "plug": {:hex, :plug, "1.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"}, - "plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.1.2", "8b0addb5908c5238fac38e442e81b6fcd32788eaa03246b4d55d147c47c5805e", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "7d722581ce865a237e14da6d946f92704101740a256bd13ec91e63c0b122fc70"}, + "plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"}, "plug_static_index_html": {:hex, :plug_static_index_html, "1.0.0", "840123d4d3975585133485ea86af73cb2600afd7f2a976f9f5fd8b3808e636a0", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "79fd4fcf34d110605c26560cbae8f23c603ec4158c08298bd4360fdea90bb5cf"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, "postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "4737ce62a31747b4c63c12b20c62307e51bb4fcd730ca0c32c280991e0606c90"}, - "prometheus": {:hex, :prometheus, "4.5.0", "8f4a2246fe0beb50af0f77c5e0a5bb78fe575c34a9655d7f8bc743aad1c6bf76", [:mix, :rebar3], [], "hexpm"}, + "prometheus": {:hex, :prometheus, "4.5.0", "8f4a2246fe0beb50af0f77c5e0a5bb78fe575c34a9655d7f8bc743aad1c6bf76", [:mix, :rebar3], [], "hexpm", "679b5215480fff612b8351f45c839d995a07ce403e42ff02f1c6b20960d41a4e"}, "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.3", "3dd4da1812b8e0dbee81ea58bb3b62ed7588f2eae0c9e97e434c46807ff82311", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "8d66289f77f913b37eda81fd287340c17e61a447549deb28efc254532b2bed82"}, "prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm", "9fd13404a48437e044b288b41f76e64acd9735fb8b0e3809f494811dfa66d0fb"}, "prometheus_phoenix": {:hex, :prometheus_phoenix, "1.3.0", "c4b527e0b3a9ef1af26bdcfbfad3998f37795b9185d475ca610fe4388fdd3bb5", [:mix], [{:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.3 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "c4d1404ac4e9d3d963da601db2a7d8ea31194f0017057fabf0cfb9bf5a6c8c75"}, "prometheus_plugs": {:hex, :prometheus_plugs, "1.1.5", "25933d48f8af3a5941dd7b621c889749894d8a1082a6ff7c67cc99dec26377c5", [:mix], [{:accept, "~> 0.1", [hex: :accept, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}, {:prometheus_process_collector, "~> 1.1", [hex: :prometheus_process_collector, repo: "hexpm", optional: true]}], "hexpm", "0273a6483ccb936d79ca19b0ab629aef0dba958697c94782bb728b920dfc6a79"}, "quack": {:hex, :quack, "0.1.1", "cca7b4da1a233757fdb44b3334fce80c94785b3ad5a602053b7a002b5a8967bf", [:mix], [{:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: false]}, {:tesla, "~> 1.2.0", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "d736bfa7444112eb840027bb887832a0e403a4a3437f48028c3b29a2dbbd2543"}, "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, - "recon": {:hex, :recon, "2.5.0", "2f7fcbec2c35034bade2f9717f77059dc54eb4e929a3049ca7ba6775c0bd66cd", [:mix, :rebar3], [], "hexpm"}, + "recon": {:hex, :recon, "2.5.0", "2f7fcbec2c35034bade2f9717f77059dc54eb4e929a3049ca7ba6775c0bd66cd", [:mix, :rebar3], [], "hexpm", "72f3840fedd94f06315c523f6cecf5b4827233bed7ae3fe135b2a0ebeab5e196"}, "remote_ip": {:git, "https://git.pleroma.social/pleroma/remote_ip.git", "825dc00aaba5a1b7c4202a532b696b595dd3bcb3", [ref: "825dc00aaba5a1b7c4202a532b696b595dd3bcb3"]}, "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"}, + "syslog": {:hex, :syslog, "1.0.6", "995970c9aa7feb380ac493302138e308d6e04fd57da95b439a6df5bb3bf75076", [:rebar3], [], "hexpm", "769ddfabd0d2a16f3f9c17eb7509951e0ca4f68363fb26f2ee51a8ec4a49881a"}, "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"}, - "tesla": {:hex, :tesla, "1.3.2", "deb92c5c9ce35e747a395ba413ca78593a4f75bf0e1545630ee2e3d34264021e", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.3", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"}, + "tesla": {:git, "https://github.com/alex-strizhakov/tesla.git", "922cc3db13b421763edbea76246b8ea61c38c6fa", [ref: "922cc3db13b421763edbea76246b8ea61c38c6fa"]}, "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"}, "trailing_format_plug": {:hex, :trailing_format_plug, "0.0.7", "64b877f912cf7273bed03379936df39894149e35137ac9509117e59866e10e45", [:mix], [{:plug, "> 0.12.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bd4fde4c15f3e993a999e019d64347489b91b7a9096af68b2bdadd192afa693f"}, "tzdata": {:hex, :tzdata, "0.5.22", "f2ba9105117ee0360eae2eca389783ef7db36d533899b2e84559404dbc77ebb8", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "cd66c8a1e6a9e121d1f538b01bef459334bb4029a1ffb4eeeb5e4eae0337e7b6"}, From a03c420b84d9901be70520d8c027ccb53449990d Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 21 Feb 2020 12:32:42 +0300 Subject: [PATCH 021/188] by default don't use gun retries remove conn depends on retry setting from config --- config/config.exs | 2 +- lib/pleroma/pool/connections.ex | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/config/config.exs b/config/config.exs index 853a53fc9..7f3a4d1b6 100644 --- a/config/config.exs +++ b/config/config.exs @@ -599,7 +599,7 @@ config :pleroma, :connections_pool, receive_connection_timeout: 250, max_connections: 250, - retry: 5, + retry: 0, retry_timeout: 100, await_up_timeout: 5_000 diff --git a/lib/pleroma/pool/connections.ex b/lib/pleroma/pool/connections.ex index c7136e0e0..d20927580 100644 --- a/lib/pleroma/pool/connections.ex +++ b/lib/pleroma/pool/connections.ex @@ -5,6 +5,8 @@ defmodule Pleroma.Pool.Connections do use GenServer + alias Pleroma.Config + require Logger @type domain :: String.t() @@ -33,7 +35,7 @@ def checkin(url, name) def checkin(url, name) when is_binary(url), do: checkin(URI.parse(url), name) def checkin(%URI{} = uri, name) do - timeout = Pleroma.Config.get([:connections_pool, :receive_connection_timeout], 250) + timeout = Config.get([:connections_pool, :receive_connection_timeout], 250) GenServer.call( name, @@ -47,7 +49,7 @@ def open_conn(url, name, opts \\ []) def open_conn(url, name, opts) when is_binary(url), do: open_conn(URI.parse(url), name, opts) def open_conn(%URI{} = uri, name, opts) do - pool_opts = Pleroma.Config.get([:connections_pool], []) + pool_opts = Config.get([:connections_pool], []) opts = opts @@ -193,12 +195,13 @@ def handle_info({:gun_up, conn_pid, _protocol}, state) do @impl true def handle_info({:gun_down, conn_pid, _protocol, _reason, _killed}, state) do + retries = Config.get([:connections_pool, :retry], 0) # we can't get info on this pid, because pid is dead state = with true <- Process.alive?(conn_pid), {key, conn} <- find_conn(state.conns, conn_pid) do - if conn.retries == 5 do - Logger.debug("closing conn if retries is eq 5 #{inspect(conn_pid)}") + if conn.retries == retries do + Logger.debug("closing conn if retries is eq #{inspect(conn_pid)}") :ok = API.close(conn.conn) put_in( From ad8f26c0a4a0a579e93547e78313d3e4ecef6ed5 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 21 Feb 2020 12:53:40 +0300 Subject: [PATCH 022/188] more info in Connections.checkin timout errors --- lib/pleroma/http/adapter/gun.ex | 13 +++++++++---- test/http_test.exs | 4 ++-- test/pool/connections_test.exs | 8 ++++++-- test/reverse_proxy/client/tesla_test.exs | 4 ++-- test/reverse_proxy/reverse_proxy_test.exs | 8 ++++---- 5 files changed, 23 insertions(+), 14 deletions(-) diff --git a/lib/pleroma/http/adapter/gun.ex b/lib/pleroma/http/adapter/gun.ex index f1018dd8d..fc40b324a 100644 --- a/lib/pleroma/http/adapter/gun.ex +++ b/lib/pleroma/http/adapter/gun.ex @@ -115,11 +115,16 @@ defp try_to_get_conn(uri, opts) do opts catch - :exit, {:timeout, _} -> + :exit, {:timeout, {_, operation, [_, {method, _}, _]}} -> + messages_len = + :gun_connections + |> Process.whereis() + |> Process.info(:message_queue_len) + Logger.warn( - "Gun connections pool checkin with timeout error #{uri.scheme}://#{ - Connections.compose_uri(uri) - }" + "Gun connections pool checkin with timeout error for #{operation} #{method} #{ + uri.scheme + }://#{Connections.compose_uri(uri)}. Messages length: #{messages_len}" ) opts diff --git a/test/http_test.exs b/test/http_test.exs index d80b96496..83c27f6e1 100644 --- a/test/http_test.exs +++ b/test/http_test.exs @@ -61,8 +61,8 @@ test "returns successfully result" do describe "connection pools" do @describetag :integration - clear_config([Pleroma.Gun.API]) do - Pleroma.Config.put([Pleroma.Gun.API], Pleroma.Gun) + clear_config(Pleroma.Gun.API) do + Pleroma.Config.put(Pleroma.Gun.API, Pleroma.Gun) end test "gun" do diff --git a/test/pool/connections_test.exs b/test/pool/connections_test.exs index 6f0e041ae..d0d711c55 100644 --- a/test/pool/connections_test.exs +++ b/test/pool/connections_test.exs @@ -15,6 +15,10 @@ defmodule Pleroma.Pool.ConnectionsTest do :ok end + clear_config([:connections_pool, :retry]) do + Pleroma.Config.put([:connections_pool, :retry], 5) + end + setup do name = :test_connections adapter = Application.get_env(:tesla, :adapter) @@ -429,8 +433,8 @@ test "remove frequently used and idle", %{name: name} do describe "integration test" do @describetag :integration - clear_config([API]) do - Pleroma.Config.put([API], Pleroma.Gun) + clear_config(API) do + Pleroma.Config.put(API, Pleroma.Gun) end test "opens connection and reuse it on next request", %{name: name} do diff --git a/test/reverse_proxy/client/tesla_test.exs b/test/reverse_proxy/client/tesla_test.exs index 75a70988c..231271b0d 100644 --- a/test/reverse_proxy/client/tesla_test.exs +++ b/test/reverse_proxy/client/tesla_test.exs @@ -8,8 +8,8 @@ defmodule Pleroma.ReverseProxy.Client.TeslaTest do alias Pleroma.ReverseProxy.Client @moduletag :integration - clear_config_all([Pleroma.Gun.API]) do - Pleroma.Config.put([Pleroma.Gun.API], Pleroma.Gun) + clear_config_all(Pleroma.Gun.API) do + Pleroma.Config.put(Pleroma.Gun.API, Pleroma.Gun) end setup do diff --git a/test/reverse_proxy/reverse_proxy_test.exs b/test/reverse_proxy/reverse_proxy_test.exs index 1ab3cc4bb..f61fc02c5 100644 --- a/test/reverse_proxy/reverse_proxy_test.exs +++ b/test/reverse_proxy/reverse_proxy_test.exs @@ -345,12 +345,12 @@ test "with content-disposition header", %{conn: conn} do describe "tesla client using gun integration" do @describetag :integration - clear_config([Pleroma.ReverseProxy.Client]) do - Pleroma.Config.put([Pleroma.ReverseProxy.Client], Pleroma.ReverseProxy.Client.Tesla) + clear_config(Pleroma.ReverseProxy.Client) do + Pleroma.Config.put(Pleroma.ReverseProxy.Client, Pleroma.ReverseProxy.Client.Tesla) end - clear_config([Pleroma.Gun.API]) do - Pleroma.Config.put([Pleroma.Gun.API], Pleroma.Gun) + clear_config(Pleroma.Gun.API) do + Pleroma.Config.put(Pleroma.Gun.API, Pleroma.Gun) end setup do From 6806df80ddb1e52aef2b89b923d9a3e2844b5aeb Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 21 Feb 2020 14:28:16 +0300 Subject: [PATCH 023/188] don't log info ssl messages --- lib/pleroma/http/adapter/gun.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/http/adapter/gun.ex b/lib/pleroma/http/adapter/gun.ex index fc40b324a..0a6872ad6 100644 --- a/lib/pleroma/http/adapter/gun.ex +++ b/lib/pleroma/http/adapter/gun.ex @@ -58,7 +58,8 @@ defp add_scheme_opts(opts, %URI{scheme: "https", host: host, port: port}) do depth: 20, reuse_sessions: false, verify_fun: - {&:ssl_verify_hostname.verify_fun/3, [check_hostname: Adapter.domain_or_fallback(host)]} + {&:ssl_verify_hostname.verify_fun/3, [check_hostname: Adapter.domain_or_fallback(host)]}, + log_level: :warning ] ] From f604f9e47061b9d47c1bb62cc7aaf44fabdf69b3 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 21 Feb 2020 14:33:55 +0300 Subject: [PATCH 024/188] hackney pool timeout --- lib/pleroma/http/connection.ex | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/http/connection.ex b/lib/pleroma/http/connection.ex index 85918341a..e2d7afbbd 100644 --- a/lib/pleroma/http/connection.ex +++ b/lib/pleroma/http/connection.ex @@ -33,8 +33,14 @@ def options(%URI{} = uri, opts \\ []) do end defp pool_timeout(opts) do - timeout = - Config.get([:pools, opts[:pool], :timeout]) || Config.get([:pools, :default, :timeout]) + {config_key, default} = + if Application.get_env(:tesla, :adapter) == Tesla.Adapter.Gun do + {:pools, Config.get([:pools, :default, :timeout])} + else + {:hackney_pools, 10_000} + end + + timeout = Config.get([config_key, opts[:pool], :timeout], default) Keyword.merge(opts, timeout: timeout) end From d44f9e3b6cfd5a0dae07f6194bfd05360afd6560 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 21 Feb 2020 16:56:55 +0300 Subject: [PATCH 025/188] fix for timeout clause --- lib/pleroma/http/adapter/gun.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/http/adapter/gun.ex b/lib/pleroma/http/adapter/gun.ex index 0a6872ad6..7b7e38d8c 100644 --- a/lib/pleroma/http/adapter/gun.ex +++ b/lib/pleroma/http/adapter/gun.ex @@ -117,7 +117,7 @@ defp try_to_get_conn(uri, opts) do opts catch :exit, {:timeout, {_, operation, [_, {method, _}, _]}} -> - messages_len = + {:message_queue_len, messages_len} = :gun_connections |> Process.whereis() |> Process.info(:message_queue_len) From 8efae966b1e87fe448a13d04eae0898c4a102c29 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 24 Feb 2020 19:56:27 +0300 Subject: [PATCH 026/188] open conn in separate task --- lib/mix/tasks/pleroma/benchmark.ex | 2 +- lib/pleroma/gun/api.ex | 7 +- lib/pleroma/gun/api/mock.ex | 5 +- lib/pleroma/gun/conn.ex | 146 +++++++++++++++ lib/pleroma/gun/gun.ex | 5 +- lib/pleroma/http/adapter/gun.ex | 21 +-- lib/pleroma/pool/connections.ex | 287 ++++++++++------------------- restarter/lib/pleroma.ex | 4 +- test/gun/gun_test.exs | 6 + test/http/adapter/gun_test.exs | 17 +- test/http/connection_test.exs | 2 +- test/pool/connections_test.exs | 186 ++++++++++--------- 12 files changed, 384 insertions(+), 304 deletions(-) diff --git a/lib/mix/tasks/pleroma/benchmark.ex b/lib/mix/tasks/pleroma/benchmark.ex index 01e079136..7a7430289 100644 --- a/lib/mix/tasks/pleroma/benchmark.ex +++ b/lib/mix/tasks/pleroma/benchmark.ex @@ -79,7 +79,7 @@ def run(["adapters"]) do start_pleroma() :ok = - Pleroma.Pool.Connections.open_conn( + Pleroma.Gun.Conn.open( "https://httpbin.org/stream-bytes/1500", :gun_connections ) diff --git a/lib/pleroma/gun/api.ex b/lib/pleroma/gun/api.ex index a0c3c5415..f79c9f443 100644 --- a/lib/pleroma/gun/api.ex +++ b/lib/pleroma/gun/api.ex @@ -6,9 +6,10 @@ defmodule Pleroma.Gun.API do @callback open(charlist(), pos_integer(), map()) :: {:ok, pid()} @callback info(pid()) :: map() @callback close(pid()) :: :ok - @callback await_up(pid) :: {:ok, atom()} | {:error, atom()} + @callback await_up(pid, pos_integer()) :: {:ok, atom()} | {:error, atom()} @callback connect(pid(), map()) :: reference() @callback await(pid(), reference()) :: {:response, :fin, 200, []} + @callback set_owner(pid(), pid()) :: :ok def open(host, port, opts), do: api().open(host, port, opts) @@ -16,11 +17,13 @@ def info(pid), do: api().info(pid) def close(pid), do: api().close(pid) - def await_up(pid), do: api().await_up(pid) + def await_up(pid, timeout \\ 5_000), do: api().await_up(pid, timeout) def connect(pid, opts), do: api().connect(pid, opts) def await(pid, ref), do: api().await(pid, ref) + def set_owner(pid, owner), do: api().set_owner(pid, owner) + defp api, do: Pleroma.Config.get([Pleroma.Gun.API], Pleroma.Gun) end diff --git a/lib/pleroma/gun/api/mock.ex b/lib/pleroma/gun/api/mock.ex index 0134b016e..6d24b0e69 100644 --- a/lib/pleroma/gun/api/mock.ex +++ b/lib/pleroma/gun/api/mock.ex @@ -118,7 +118,10 @@ def open('localhost', 9050, _) do end @impl API - def await_up(_pid), do: {:ok, :http} + def await_up(_pid, _timeout), do: {:ok, :http} + + @impl API + def set_owner(_pid, _owner), do: :ok @impl API def connect(pid, %{host: _, port: 80}) do diff --git a/lib/pleroma/gun/conn.ex b/lib/pleroma/gun/conn.ex index 2474829d6..ddb9f30b0 100644 --- a/lib/pleroma/gun/conn.ex +++ b/lib/pleroma/gun/conn.ex @@ -6,6 +6,11 @@ defmodule Pleroma.Gun.Conn do @moduledoc """ Struct for gun connection data """ + alias Pleroma.Gun.API + alias Pleroma.Pool.Connections + + require Logger + @type gun_state :: :up | :down @type conn_state :: :active | :idle @@ -26,4 +31,145 @@ defmodule Pleroma.Gun.Conn do last_reference: 0, crf: 1, retries: 0 + + @spec open(String.t() | URI.t(), atom(), keyword()) :: :ok | nil + def open(url, name, opts \\ []) + def open(url, name, opts) when is_binary(url), do: open(URI.parse(url), name, opts) + + def open(%URI{} = uri, name, opts) do + pool_opts = Pleroma.Config.get([:connections_pool], []) + + opts = + opts + |> Enum.into(%{}) + |> Map.put_new(:retry, pool_opts[:retry] || 0) + |> Map.put_new(:retry_timeout, pool_opts[:retry_timeout] || 100) + |> Map.put_new(:await_up_timeout, pool_opts[:await_up_timeout] || 5_000) + + key = "#{uri.scheme}:#{uri.host}:#{uri.port}" + + Logger.debug("opening new connection #{Connections.compose_uri_log(uri)}") + + conn_pid = + if Connections.count(name) < opts[:max_connection] do + do_open(uri, opts) + else + try_do_open(name, uri, opts) + end + + if is_pid(conn_pid) do + conn = %Pleroma.Gun.Conn{ + conn: conn_pid, + gun_state: :up, + conn_state: :active, + last_reference: :os.system_time(:second) + } + + :ok = API.set_owner(conn_pid, Process.whereis(name)) + Connections.add_conn(name, key, conn) + end + end + + defp do_open(uri, %{proxy: {proxy_host, proxy_port}} = opts) do + connect_opts = + uri + |> destination_opts() + |> add_http2_opts(uri.scheme, Map.get(opts, :tls_opts, [])) + + with open_opts <- Map.delete(opts, :tls_opts), + {:ok, conn} <- API.open(proxy_host, proxy_port, open_opts), + {:ok, _} <- API.await_up(conn, opts[:await_up_timeout]), + stream <- API.connect(conn, connect_opts), + {:response, :fin, 200, _} <- API.await(conn, stream) do + conn + else + error -> + Logger.warn( + "Received error on opening connection with http proxy #{ + Connections.compose_uri_log(uri) + } #{inspect(error)}" + ) + + nil + end + end + + defp do_open(uri, %{proxy: {proxy_type, proxy_host, proxy_port}} = opts) do + version = + proxy_type + |> to_string() + |> String.last() + |> case do + "4" -> 4 + _ -> 5 + end + + socks_opts = + uri + |> destination_opts() + |> add_http2_opts(uri.scheme, Map.get(opts, :tls_opts, [])) + |> Map.put(:version, version) + + opts = + opts + |> Map.put(:protocols, [:socks]) + |> Map.put(:socks_opts, socks_opts) + + with {:ok, conn} <- API.open(proxy_host, proxy_port, opts), + {:ok, _} <- API.await_up(conn, opts[:await_up_timeout]) do + conn + else + error -> + Logger.warn( + "Received error on opening connection with socks proxy #{ + Connections.compose_uri_log(uri) + } #{inspect(error)}" + ) + + nil + end + end + + defp do_open(%URI{host: host, port: port} = uri, opts) do + {_type, host} = Pleroma.HTTP.Adapter.domain_or_ip(host) + + with {:ok, conn} <- API.open(host, port, opts), + {:ok, _} <- API.await_up(conn, opts[:await_up_timeout]) do + conn + else + error -> + Logger.warn( + "Received error on opening connection #{Connections.compose_uri_log(uri)} #{ + inspect(error) + }" + ) + + nil + end + end + + defp destination_opts(%URI{host: host, port: port}) do + {_type, host} = Pleroma.HTTP.Adapter.domain_or_ip(host) + %{host: host, port: port} + end + + defp add_http2_opts(opts, "https", tls_opts) do + Map.merge(opts, %{protocols: [:http2], transport: :tls, tls_opts: tls_opts}) + end + + defp add_http2_opts(opts, _, _), do: opts + + defp try_do_open(name, uri, opts) do + Logger.debug("try to open conn #{Connections.compose_uri_log(uri)}") + + with [{close_key, least_used} | _conns] <- + Connections.get_unused_conns(name), + :ok <- Pleroma.Gun.API.close(least_used.conn) do + Connections.remove_conn(name, close_key) + + do_open(uri, opts) + else + [] -> nil + end + end end diff --git a/lib/pleroma/gun/gun.ex b/lib/pleroma/gun/gun.ex index 4a1bbc95f..da82983b1 100644 --- a/lib/pleroma/gun/gun.ex +++ b/lib/pleroma/gun/gun.ex @@ -32,7 +32,7 @@ def open(host, port, opts \\ %{}), do: :gun.open(host, port, Map.take(opts, @gun defdelegate close(pid), to: :gun @impl API - defdelegate await_up(pid), to: :gun + defdelegate await_up(pid, timeout \\ 5_000), to: :gun @impl API defdelegate connect(pid, opts), to: :gun @@ -42,4 +42,7 @@ def open(host, port, opts \\ %{}), do: :gun.open(host, port, Map.take(opts, @gun @spec flush(pid() | reference()) :: :ok defdelegate flush(pid), to: :gun + + @impl API + defdelegate set_owner(pid, owner), to: :gun end diff --git a/lib/pleroma/http/adapter/gun.ex b/lib/pleroma/http/adapter/gun.ex index 7b7e38d8c..908d71898 100644 --- a/lib/pleroma/http/adapter/gun.ex +++ b/lib/pleroma/http/adapter/gun.ex @@ -12,7 +12,7 @@ defmodule Pleroma.HTTP.Adapter.Gun do alias Pleroma.Pool.Connections @defaults [ - connect_timeout: 20_000, + connect_timeout: 5_000, domain_lookup_timeout: 5_000, tls_handshake_timeout: 5_000, retry: 0, @@ -94,13 +94,11 @@ defp try_to_get_conn(uri, opts) do "Gun connections pool checkin was not successful. Trying to open conn for next request." ) - :ok = Connections.open_conn(uri, :gun_connections, opts) + Task.start(fn -> Pleroma.Gun.Conn.open(uri, :gun_connections, opts) end) opts conn when is_pid(conn) -> - Logger.debug( - "received conn #{inspect(conn)} #{uri.scheme}://#{Connections.compose_uri(uri)}" - ) + Logger.debug("received conn #{inspect(conn)} #{Connections.compose_uri_log(uri)}") opts |> Keyword.put(:conn, conn) @@ -109,13 +107,14 @@ defp try_to_get_conn(uri, opts) do rescue error -> Logger.warn( - "Gun connections pool checkin caused error #{uri.scheme}://#{ - Connections.compose_uri(uri) - } #{inspect(error)}" + "Gun connections pool checkin caused error #{Connections.compose_uri_log(uri)} #{ + inspect(error) + }" ) opts catch + # TODO: here must be no timeouts :exit, {:timeout, {_, operation, [_, {method, _}, _]}} -> {:message_queue_len, messages_len} = :gun_connections @@ -124,15 +123,15 @@ defp try_to_get_conn(uri, opts) do Logger.warn( "Gun connections pool checkin with timeout error for #{operation} #{method} #{ - uri.scheme - }://#{Connections.compose_uri(uri)}. Messages length: #{messages_len}" + Connections.compose_uri_log(uri) + }. Messages length: #{messages_len}" ) opts :exit, error -> Logger.warn( - "Gun pool checkin exited with error #{uri.scheme}://#{Connections.compose_uri(uri)} #{ + "Gun pool checkin exited with error #{Connections.compose_uri_log(uri)} #{ inspect(error) }" ) diff --git a/lib/pleroma/pool/connections.ex b/lib/pleroma/pool/connections.ex index d20927580..a444f822f 100644 --- a/lib/pleroma/pool/connections.ex +++ b/lib/pleroma/pool/connections.ex @@ -20,7 +20,6 @@ defmodule Pleroma.Pool.Connections do defstruct conns: %{}, opts: [] alias Pleroma.Gun.API - alias Pleroma.Gun.Conn @spec start_link({atom(), keyword()}) :: {:ok, pid()} def start_link({name, opts}) do @@ -44,23 +43,6 @@ def checkin(%URI{} = uri, name) do ) end - @spec open_conn(String.t() | URI.t(), atom(), keyword()) :: :ok - def open_conn(url, name, opts \\ []) - def open_conn(url, name, opts) when is_binary(url), do: open_conn(URI.parse(url), name, opts) - - def open_conn(%URI{} = uri, name, opts) do - pool_opts = Config.get([:connections_pool], []) - - opts = - opts - |> Enum.into(%{}) - |> Map.put_new(:retry, pool_opts[:retry] || 0) - |> Map.put_new(:retry_timeout, pool_opts[:retry_timeout] || 100) - |> Map.put_new(:await_up_timeout, pool_opts[:await_up_timeout] || 5_000) - - GenServer.cast(name, {:open_conn, %{opts: opts, uri: uri}}) - end - @spec alive?(atom()) :: boolean() def alive?(name) do pid = Process.whereis(name) @@ -72,23 +54,37 @@ def get_state(name) do GenServer.call(name, :state) end + @spec count(atom()) :: pos_integer() + def count(name) do + GenServer.call(name, :count) + end + + @spec get_unused_conns(atom()) :: [{domain(), conn()}] + def get_unused_conns(name) do + GenServer.call(name, :unused_conns) + end + @spec checkout(pid(), pid(), atom()) :: :ok def checkout(conn, pid, name) do GenServer.cast(name, {:checkout, conn, pid}) end + @spec add_conn(atom(), String.t(), Pleroma.Gun.Conn.t()) :: :ok + def add_conn(name, key, conn) do + GenServer.cast(name, {:add_conn, key, conn}) + end + + @spec remove_conn(atom(), String.t()) :: :ok + def remove_conn(name, key) do + GenServer.cast(name, {:remove_conn, key}) + end + @impl true - def handle_cast({:open_conn, %{opts: opts, uri: uri}}, state) do - Logger.debug("opening new #{compose_uri(uri)}") - max_connections = state.opts[:max_connections] + def handle_cast({:add_conn, key, conn}, state) do + state = put_in(state.conns[key], conn) - key = compose_key(uri) - - if Enum.count(state.conns) < max_connections do - open_conn(key, uri, state, opts) - else - try_to_open_conn(key, uri, state, opts) - end + Process.monitor(conn.conn) + {:noreply, state} end @impl true @@ -120,14 +116,20 @@ def handle_cast({:checkout, conn_pid, pid}, state) do {:noreply, state} end + @impl true + def handle_cast({:remove_conn, key}, state) do + state = put_in(state.conns, Map.delete(state.conns, key)) + {:noreply, state} + end + @impl true def handle_call({:checkin, uri}, from, state) do - Logger.debug("checkin #{compose_uri(uri)}") - key = compose_key(uri) + key = "#{uri.scheme}:#{uri.host}:#{uri.port}" + Logger.debug("checkin #{key}") case state.conns[key] do %{conn: conn, gun_state: gun_state} = current_conn when gun_state == :up -> - Logger.debug("reusing conn #{compose_uri(uri)}") + Logger.debug("reusing conn #{key}") with time <- :os.system_time(:second), last_reference <- time - current_conn.last_reference, @@ -154,12 +156,31 @@ def handle_call({:checkin, uri}, from, state) do @impl true def handle_call(:state, _from, state), do: {:reply, state, state} + @impl true + def handle_call(:count, _from, state) do + {:reply, Enum.count(state.conns), state} + end + + @impl true + def handle_call(:unused_conns, _from, state) do + unused_conns = + state.conns + |> Enum.filter(fn {_k, v} -> + v.conn_state == :idle and v.used_by == [] + end) + |> Enum.sort(fn {_x_k, x}, {_y_k, y} -> + x.crf <= y.crf and x.last_reference <= y.last_reference + end) + + {:reply, unused_conns, state} + end + @impl true def handle_info({:gun_up, conn_pid, _protocol}, state) do state = - with true <- Process.alive?(conn_pid), - conn_key when is_binary(conn_key) <- compose_key_gun_info(conn_pid), + with conn_key when is_binary(conn_key) <- compose_key_gun_info(conn_pid), {key, conn} <- find_conn(state.conns, conn_pid, conn_key), + {true, key} <- {Process.alive?(conn_pid), key}, time <- :os.system_time(:second), last_reference <- time - conn.last_reference, current_crf <- crf(last_reference, 100, conn.crf) do @@ -176,14 +197,16 @@ def handle_info({:gun_up, conn_pid, _protocol}, state) do Logger.debug(":gun.info caused error") state - false -> + {false, key} -> Logger.debug(":gun_up message for closed conn #{inspect(conn_pid)}") - state + + put_in( + state.conns, + Map.delete(state.conns, key) + ) nil -> - Logger.debug( - ":gun_up message for alive conn #{inspect(conn_pid)}, but deleted from state" - ) + Logger.debug(":gun_up message for conn which is not found in state") :ok = API.close(conn_pid) @@ -198,8 +221,8 @@ def handle_info({:gun_down, conn_pid, _protocol, _reason, _killed}, state) do retries = Config.get([:connections_pool, :retry], 0) # we can't get info on this pid, because pid is dead state = - with true <- Process.alive?(conn_pid), - {key, conn} <- find_conn(state.conns, conn_pid) do + with {key, conn} <- find_conn(state.conns, conn_pid), + {true, key} <- {Process.alive?(conn_pid), key} do if conn.retries == retries do Logger.debug("closing conn if retries is eq #{inspect(conn_pid)}") :ok = API.close(conn.conn) @@ -216,15 +239,17 @@ def handle_info({:gun_down, conn_pid, _protocol, _reason, _killed}, state) do }) end else - false -> + {false, key} -> # gun can send gun_down for closed conn, maybe connection is not closed yet Logger.debug(":gun_down message for closed conn #{inspect(conn_pid)}") - state + + put_in( + state.conns, + Map.delete(state.conns, key) + ) nil -> - Logger.debug( - ":gun_down message for alive conn #{inspect(conn_pid)}, but deleted from state" - ) + Logger.debug(":gun_down message for conn which is not found in state") :ok = API.close(conn_pid) @@ -234,7 +259,29 @@ def handle_info({:gun_down, conn_pid, _protocol, _reason, _killed}, state) do {:noreply, state} end - defp compose_key(%URI{scheme: scheme, host: host, port: port}), do: "#{scheme}:#{host}:#{port}" + @impl true + def handle_info({:DOWN, _ref, :process, conn_pid, reason}, state) do + Logger.debug("received DOWM message for #{inspect(conn_pid)} reason -> #{inspect(reason)}") + + state = + with {key, conn} <- find_conn(state.conns, conn_pid) do + Enum.each(conn.used_by, fn {pid, _ref} -> + Process.exit(pid, reason) + end) + + put_in( + state.conns, + Map.delete(state.conns, key) + ) + else + nil -> + Logger.debug(":DOWN message for conn which is not found in state") + + state + end + + {:noreply, state} + end defp compose_key_gun_info(pid) do try do @@ -265,153 +312,11 @@ defp find_conn(conns, conn_pid, conn_key) do end) end - defp open_conn(key, uri, state, %{proxy: {proxy_host, proxy_port}} = opts) do - connect_opts = - uri - |> destination_opts() - |> add_http2_opts(uri.scheme, Map.get(opts, :tls_opts, [])) - - with open_opts <- Map.delete(opts, :tls_opts), - {:ok, conn} <- API.open(proxy_host, proxy_port, open_opts), - {:ok, _} <- API.await_up(conn), - stream <- API.connect(conn, connect_opts), - {:response, :fin, 200, _} <- API.await(conn, stream), - state <- - put_in(state.conns[key], %Conn{ - conn: conn, - gun_state: :up, - conn_state: :active, - last_reference: :os.system_time(:second) - }) do - {:noreply, state} - else - error -> - Logger.warn( - "Received error on opening connection with http proxy #{uri.scheme}://#{ - compose_uri(uri) - }: #{inspect(error)}" - ) - - {:noreply, state} - end - end - - defp open_conn(key, uri, state, %{proxy: {proxy_type, proxy_host, proxy_port}} = opts) do - version = - proxy_type - |> to_string() - |> String.last() - |> case do - "4" -> 4 - _ -> 5 - end - - socks_opts = - uri - |> destination_opts() - |> add_http2_opts(uri.scheme, Map.get(opts, :tls_opts, [])) - |> Map.put(:version, version) - - opts = - opts - |> Map.put(:protocols, [:socks]) - |> Map.put(:socks_opts, socks_opts) - - with {:ok, conn} <- API.open(proxy_host, proxy_port, opts), - {:ok, _} <- API.await_up(conn), - state <- - put_in(state.conns[key], %Conn{ - conn: conn, - gun_state: :up, - conn_state: :active, - last_reference: :os.system_time(:second) - }) do - {:noreply, state} - else - error -> - Logger.warn( - "Received error on opening connection with socks proxy #{uri.scheme}://#{ - compose_uri(uri) - }: #{inspect(error)}" - ) - - {:noreply, state} - end - end - - defp open_conn(key, %URI{host: host, port: port} = uri, state, opts) do - Logger.debug("opening conn #{compose_uri(uri)}") - {_type, host} = Pleroma.HTTP.Adapter.domain_or_ip(host) - - with {:ok, conn} <- API.open(host, port, opts), - {:ok, _} <- API.await_up(conn), - state <- - put_in(state.conns[key], %Conn{ - conn: conn, - gun_state: :up, - conn_state: :active, - last_reference: :os.system_time(:second) - }) do - Logger.debug("new conn opened #{compose_uri(uri)}") - Logger.debug("replying to the call #{compose_uri(uri)}") - {:noreply, state} - else - error -> - Logger.warn( - "Received error on opening connection #{uri.scheme}://#{compose_uri(uri)}: #{ - inspect(error) - }" - ) - - {:noreply, state} - end - end - - defp destination_opts(%URI{host: host, port: port}) do - {_type, host} = Pleroma.HTTP.Adapter.domain_or_ip(host) - %{host: host, port: port} - end - - defp add_http2_opts(opts, "https", tls_opts) do - Map.merge(opts, %{protocols: [:http2], transport: :tls, tls_opts: tls_opts}) - end - - defp add_http2_opts(opts, _, _), do: opts - - @spec get_unused_conns(map()) :: [{domain(), conn()}] - def get_unused_conns(conns) do - conns - |> Enum.filter(fn {_k, v} -> - v.conn_state == :idle and v.used_by == [] - end) - |> Enum.sort(fn {_x_k, x}, {_y_k, y} -> - x.crf <= y.crf and x.last_reference <= y.last_reference - end) - end - - defp try_to_open_conn(key, uri, state, opts) do - Logger.debug("try to open conn #{compose_uri(uri)}") - - with [{close_key, least_used} | _conns] <- get_unused_conns(state.conns), - :ok <- API.close(least_used.conn), - state <- - put_in( - state.conns, - Map.delete(state.conns, close_key) - ) do - Logger.debug( - "least used conn found and closed #{inspect(least_used.conn)} #{compose_uri(uri)}" - ) - - open_conn(key, uri, state, opts) - else - [] -> {:noreply, state} - end - end - def crf(current, steps, crf) do 1 + :math.pow(0.5, current / steps) * crf end - def compose_uri(%URI{} = uri), do: "#{uri.host}#{uri.path}" + def compose_uri_log(%URI{scheme: scheme, host: host, path: path}) do + "#{scheme}://#{host}#{path}" + end end diff --git a/restarter/lib/pleroma.ex b/restarter/lib/pleroma.ex index d7817909d..4ade890f9 100644 --- a/restarter/lib/pleroma.ex +++ b/restarter/lib/pleroma.ex @@ -44,7 +44,7 @@ def handle_cast(:need_reboot, state) do end def handle_cast({:restart, :test, _}, state) do - Logger.warn("pleroma restarted") + Logger.warn("pleroma manually restarted") {:noreply, Map.put(state, :need_reboot?, false)} end @@ -57,7 +57,7 @@ def handle_cast({:restart, _, delay}, state) do def handle_cast({:after_boot, _}, %{after_boot: true} = state), do: {:noreply, state} def handle_cast({:after_boot, :test}, state) do - Logger.warn("pleroma restarted") + Logger.warn("pleroma restarted after boot") {:noreply, Map.put(state, :after_boot, true)} end diff --git a/test/gun/gun_test.exs b/test/gun/gun_test.exs index 7f185617c..9f3e0f938 100644 --- a/test/gun/gun_test.exs +++ b/test/gun/gun_test.exs @@ -19,6 +19,12 @@ test "opens connection and receive response" do assert json = receive_response(conn, ref) assert %{"args" => %{"a" => "b", "c" => "d"}} = Jason.decode!(json) + + {:ok, pid} = Task.start(fn -> Process.sleep(50) end) + + :ok = :gun.set_owner(conn, pid) + + assert :gun.info(conn).owner == pid end defp receive_response(conn, ref, acc \\ "") do diff --git a/test/http/adapter/gun_test.exs b/test/http/adapter/gun_test.exs index ef1b4a882..a8dcbae04 100644 --- a/test/http/adapter/gun_test.exs +++ b/test/http/adapter/gun_test.exs @@ -7,6 +7,7 @@ defmodule Pleroma.HTTP.Adapter.GunTest do use Pleroma.Tests.Helpers import ExUnit.CaptureLog alias Pleroma.Config + alias Pleroma.Gun.Conn alias Pleroma.HTTP.Adapter.Gun alias Pleroma.Pool.Connections @@ -72,7 +73,7 @@ test "https url with non standart port" do test "receive conn by default" do uri = URI.parse("http://another-domain.com") - :ok = Connections.open_conn(uri, :gun_connections) + :ok = Conn.open(uri, :gun_connections) received_opts = Gun.options(uri) assert received_opts[:close_conn] == false @@ -81,7 +82,7 @@ test "receive conn by default" do test "don't receive conn if receive_conn is false" do uri = URI.parse("http://another-domain2.com") - :ok = Connections.open_conn(uri, :gun_connections) + :ok = Conn.open(uri, :gun_connections) opts = [receive_conn: false] received_opts = Gun.options(opts, uri) @@ -118,7 +119,7 @@ test "merges with defaul http adapter config" do test "default ssl adapter opts with connection" do uri = URI.parse("https://some-domain.com") - :ok = Connections.open_conn(uri, :gun_connections) + :ok = Conn.open(uri, :gun_connections) opts = Gun.options(uri) @@ -167,7 +168,7 @@ test "passed opts have more weight than defaults" do describe "after_request/1" do test "body_as not chunks" do uri = URI.parse("http://some-domain.com") - :ok = Connections.open_conn(uri, :gun_connections) + :ok = Conn.open(uri, :gun_connections) opts = Gun.options(uri) :ok = Gun.after_request(opts) conn = opts[:conn] @@ -185,7 +186,7 @@ test "body_as not chunks" do test "body_as chunks" do uri = URI.parse("http://some-domain.com") - :ok = Connections.open_conn(uri, :gun_connections) + :ok = Conn.open(uri, :gun_connections) opts = Gun.options([body_as: :chunks], uri) :ok = Gun.after_request(opts) conn = opts[:conn] @@ -205,7 +206,7 @@ test "body_as chunks" do test "with no connection" do uri = URI.parse("http://uniq-domain.com") - :ok = Connections.open_conn(uri, :gun_connections) + :ok = Conn.open(uri, :gun_connections) opts = Gun.options([body_as: :chunks], uri) conn = opts[:conn] @@ -227,7 +228,7 @@ test "with no connection" do test "with ipv4" do uri = URI.parse("http://127.0.0.1") - :ok = Connections.open_conn(uri, :gun_connections) + :ok = Conn.open(uri, :gun_connections) opts = Gun.options(uri) send(:gun_connections, {:gun_up, opts[:conn], :http}) :ok = Gun.after_request(opts) @@ -246,7 +247,7 @@ test "with ipv4" do test "with ipv6" do uri = URI.parse("http://[2a03:2880:f10c:83:face:b00c:0:25de]") - :ok = Connections.open_conn(uri, :gun_connections) + :ok = Conn.open(uri, :gun_connections) opts = Gun.options(uri) send(:gun_connections, {:gun_up, opts[:conn], :http}) :ok = Gun.after_request(opts) diff --git a/test/http/connection_test.exs b/test/http/connection_test.exs index c1ff0cc21..53ccbc9cd 100644 --- a/test/http/connection_test.exs +++ b/test/http/connection_test.exs @@ -124,7 +124,7 @@ test "default ssl adapter opts with connection" do uri = URI.parse("https://some-domain.com") pid = Process.whereis(:federation) - :ok = Pleroma.Pool.Connections.open_conn(uri, :gun_connections, genserver_pid: pid) + :ok = Pleroma.Gun.Conn.open(uri, :gun_connections, genserver_pid: pid) opts = Connection.options(uri) diff --git a/test/pool/connections_test.exs b/test/pool/connections_test.exs index d0d711c55..f766e3b5f 100644 --- a/test/pool/connections_test.exs +++ b/test/pool/connections_test.exs @@ -45,7 +45,7 @@ test "opens connection and reuse it on next request", %{name: name} do url = "http://some-domain.com" key = "http:some-domain.com:80" refute Connections.checkin(url, name) - :ok = Connections.open_conn(url, name) + :ok = Conn.open(url, name) conn = Connections.checkin(url, name) assert is_pid(conn) @@ -110,7 +110,7 @@ test "reuse connection for idna domains", %{name: name} do url = "http://ですsome-domain.com" refute Connections.checkin(url, name) - :ok = Connections.open_conn(url, name) + :ok = Conn.open(url, name) conn = Connections.checkin(url, name) assert is_pid(conn) @@ -139,7 +139,7 @@ test "reuse for ipv4", %{name: name} do refute Connections.checkin(url, name) - :ok = Connections.open_conn(url, name) + :ok = Conn.open(url, name) conn = Connections.checkin(url, name) assert is_pid(conn) @@ -182,7 +182,7 @@ test "reuse for ipv6", %{name: name} do refute Connections.checkin(url, name) - :ok = Connections.open_conn(url, name) + :ok = Conn.open(url, name) conn = Connections.checkin(url, name) assert is_pid(conn) @@ -209,7 +209,7 @@ test "reuse for ipv6", %{name: name} do test "up and down ipv4", %{name: name} do self = self() url = "http://127.0.0.1" - :ok = Connections.open_conn(url, name) + :ok = Conn.open(url, name) conn = Connections.checkin(url, name) send(name, {:gun_down, conn, nil, nil, nil}) send(name, {:gun_up, conn, nil}) @@ -229,7 +229,7 @@ test "up and down ipv4", %{name: name} do test "up and down ipv6", %{name: name} do self = self() url = "http://[2a03:2880:f10c:83:face:b00c:0:25de]" - :ok = Connections.open_conn(url, name) + :ok = Conn.open(url, name) conn = Connections.checkin(url, name) send(name, {:gun_down, conn, nil, nil, nil}) send(name, {:gun_up, conn, nil}) @@ -253,13 +253,13 @@ test "reuses connection based on protocol", %{name: name} do https_key = "https:some-domain.com:443" refute Connections.checkin(http_url, name) - :ok = Connections.open_conn(http_url, name) + :ok = Conn.open(http_url, name) conn = Connections.checkin(http_url, name) assert is_pid(conn) assert Process.alive?(conn) refute Connections.checkin(https_url, name) - :ok = Connections.open_conn(https_url, name) + :ok = Conn.open(https_url, name) https_conn = Connections.checkin(https_url, name) refute conn == https_conn @@ -288,17 +288,17 @@ test "connection can't get up", %{name: name} do url = "http://gun-not-up.com" assert capture_log(fn -> - :ok = Connections.open_conn(url, name) + refute Conn.open(url, name) refute Connections.checkin(url, name) end) =~ - "Received error on opening connection http://gun-not-up.com: {:error, :timeout}" + "Received error on opening connection http://gun-not-up.com {:error, :timeout}" end test "process gun_down message and then gun_up", %{name: name} do self = self() url = "http://gun-down-and-up.com" key = "http:gun-down-and-up.com:80" - :ok = Connections.open_conn(url, name) + :ok = Conn.open(url, name) conn = Connections.checkin(url, name) assert is_pid(conn) @@ -347,7 +347,7 @@ test "process gun_down message and then gun_up", %{name: name} do test "async processes get same conn for same domain", %{name: name} do url = "http://some-domain.com" - :ok = Connections.open_conn(url, name) + :ok = Conn.open(url, name) tasks = for _ <- 1..5 do @@ -381,8 +381,8 @@ test "remove frequently used and idle", %{name: name} do self = self() http_url = "http://some-domain.com" https_url = "https://some-domain.com" - :ok = Connections.open_conn(https_url, name) - :ok = Connections.open_conn(http_url, name) + :ok = Conn.open(https_url, name) + :ok = Conn.open(http_url, name) conn1 = Connections.checkin(https_url, name) @@ -413,7 +413,7 @@ test "remove frequently used and idle", %{name: name} do :ok = Connections.checkout(conn1, self, name) another_url = "http://another-domain.com" - :ok = Connections.open_conn(another_url, name) + :ok = Conn.open(another_url, name) conn = Connections.checkin(another_url, name) %Connections{ @@ -437,9 +437,19 @@ test "remove frequently used and idle", %{name: name} do Pleroma.Config.put(API, Pleroma.Gun) end + test "opens connection and change owner", %{name: name} do + url = "https://httpbin.org" + :ok = Conn.open(url, name) + conn = Connections.checkin(url, name) + + pid = Process.whereis(name) + + assert :gun.info(conn).owner == pid + end + test "opens connection and reuse it on next request", %{name: name} do url = "http://httpbin.org" - :ok = Connections.open_conn(url, name) + :ok = Conn.open(url, name) Process.sleep(250) conn = Connections.checkin(url, name) @@ -462,7 +472,7 @@ test "opens connection and reuse it on next request", %{name: name} do test "opens ssl connection and reuse it on next request", %{name: name} do url = "https://httpbin.org" - :ok = Connections.open_conn(url, name) + :ok = Conn.open(url, name) Process.sleep(1_000) conn = Connections.checkin(url, name) @@ -488,8 +498,8 @@ test "remove frequently used and idle", %{name: name} do https1 = "https://www.google.com" https2 = "https://httpbin.org" - :ok = Connections.open_conn(https1, name) - :ok = Connections.open_conn(https2, name) + :ok = Conn.open(https1, name) + :ok = Conn.open(https2, name) Process.sleep(1_500) conn = Connections.checkin(https1, name) @@ -513,7 +523,7 @@ test "remove frequently used and idle", %{name: name} do :ok = Connections.checkout(conn, self, name) http = "http://httpbin.org" Process.sleep(1_000) - :ok = Connections.open_conn(http, name) + :ok = Conn.open(http, name) conn = Connections.checkin(http, name) %Connections{ @@ -535,8 +545,8 @@ test "remove earlier used and idle", %{name: name} do https1 = "https://www.google.com" https2 = "https://httpbin.org" - :ok = Connections.open_conn(https1, name) - :ok = Connections.open_conn(https2, name) + :ok = Conn.open(https1, name) + :ok = Conn.open(https2, name) Process.sleep(1_500) Connections.checkin(https1, name) @@ -563,7 +573,7 @@ test "remove earlier used and idle", %{name: name} do :ok = Connections.checkout(conn, self, name) http = "http://httpbin.org" - :ok = Connections.open_conn(http, name) + :ok = Conn.open(http, name) Process.sleep(1_000) conn = Connections.checkin(http, name) @@ -587,8 +597,8 @@ test "doesn't open new conn on pool overflow", %{name: name} do https1 = "https://www.google.com" https2 = "https://httpbin.org" - :ok = Connections.open_conn(https1, name) - :ok = Connections.open_conn(https2, name) + :ok = Conn.open(https1, name) + :ok = Conn.open(https2, name) Process.sleep(1_000) Connections.checkin(https1, name) conn1 = Connections.checkin(https1, name) @@ -639,8 +649,8 @@ test "get idle connection with the smallest crf", %{ https1 = "https://www.google.com" https2 = "https://httpbin.org" - :ok = Connections.open_conn(https1, name) - :ok = Connections.open_conn(https2, name) + :ok = Conn.open(https1, name) + :ok = Conn.open(https2, name) Process.sleep(1_500) Connections.checkin(https1, name) Connections.checkin(https2, name) @@ -694,7 +704,7 @@ test "get idle connection with the smallest crf", %{ } = Connections.get_state(name) http = "http://httpbin.org" - :ok = Connections.open_conn(http, name) + :ok = Conn.open(http, name) Process.sleep(1_000) conn = Connections.checkin(http, name) @@ -725,7 +735,7 @@ test "get idle connection with the smallest crf", %{ test "as ip", %{name: name} do url = "http://proxy-string.com" key = "http:proxy-string.com:80" - :ok = Connections.open_conn(url, name, proxy: {{127, 0, 0, 1}, 8123}) + :ok = Conn.open(url, name, proxy: {{127, 0, 0, 1}, 8123}) conn = Connections.checkin(url, name) @@ -745,7 +755,7 @@ test "as ip", %{name: name} do test "as host", %{name: name} do url = "http://proxy-tuple-atom.com" - :ok = Connections.open_conn(url, name, proxy: {'localhost', 9050}) + :ok = Conn.open(url, name, proxy: {'localhost', 9050}) conn = Connections.checkin(url, name) %Connections{ @@ -765,7 +775,7 @@ test "as host", %{name: name} do test "as ip and ssl", %{name: name} do url = "https://proxy-string.com" - :ok = Connections.open_conn(url, name, proxy: {{127, 0, 0, 1}, 8123}) + :ok = Conn.open(url, name, proxy: {{127, 0, 0, 1}, 8123}) conn = Connections.checkin(url, name) %Connections{ @@ -784,7 +794,7 @@ test "as ip and ssl", %{name: name} do test "as host and ssl", %{name: name} do url = "https://proxy-tuple-atom.com" - :ok = Connections.open_conn(url, name, proxy: {'localhost', 9050}) + :ok = Conn.open(url, name, proxy: {'localhost', 9050}) conn = Connections.checkin(url, name) %Connections{ @@ -804,7 +814,7 @@ test "as host and ssl", %{name: name} do test "with socks type", %{name: name} do url = "http://proxy-socks.com" - :ok = Connections.open_conn(url, name, proxy: {:socks5, 'localhost', 1234}) + :ok = Conn.open(url, name, proxy: {:socks5, 'localhost', 1234}) conn = Connections.checkin(url, name) @@ -825,7 +835,7 @@ test "with socks type", %{name: name} do test "with socks4 type and ssl", %{name: name} do url = "https://proxy-socks.com" - :ok = Connections.open_conn(url, name, proxy: {:socks4, 'localhost', 1234}) + :ok = Conn.open(url, name, proxy: {:socks4, 'localhost', 1234}) conn = Connections.checkin(url, name) @@ -892,71 +902,75 @@ test "recently used will have higher crf", %{crf: crf} do end describe "get_unused_conns/1" do - test "crf is equalent, sorting by reference" do - conns = %{ - "1" => %Conn{ - conn_state: :idle, - last_reference: now() - 1 - }, - "2" => %Conn{ - conn_state: :idle, - last_reference: now() - } - } + test "crf is equalent, sorting by reference", %{name: name} do + Connections.add_conn(name, "1", %Conn{ + conn_state: :idle, + last_reference: now() - 1 + }) - assert [{"1", _unused_conn} | _others] = Connections.get_unused_conns(conns) + Connections.add_conn(name, "2", %Conn{ + conn_state: :idle, + last_reference: now() + }) + + assert [{"1", _unused_conn} | _others] = Connections.get_unused_conns(name) end - test "reference is equalent, sorting by crf" do - conns = %{ - "1" => %Conn{ - conn_state: :idle, - crf: 1.999 - }, - "2" => %Conn{ - conn_state: :idle, - crf: 2 - } - } + test "reference is equalent, sorting by crf", %{name: name} do + Connections.add_conn(name, "1", %Conn{ + conn_state: :idle, + crf: 1.999 + }) - assert [{"1", _unused_conn} | _others] = Connections.get_unused_conns(conns) + Connections.add_conn(name, "2", %Conn{ + conn_state: :idle, + crf: 2 + }) + + assert [{"1", _unused_conn} | _others] = Connections.get_unused_conns(name) end - test "higher crf and lower reference" do - conns = %{ - "1" => %Conn{ - conn_state: :idle, - crf: 3, - last_reference: now() - 1 - }, - "2" => %Conn{ - conn_state: :idle, - crf: 2, - last_reference: now() - } - } + test "higher crf and lower reference", %{name: name} do + Connections.add_conn(name, "1", %Conn{ + conn_state: :idle, + crf: 3, + last_reference: now() - 1 + }) - assert [{"2", _unused_conn} | _others] = Connections.get_unused_conns(conns) + Connections.add_conn(name, "2", %Conn{ + conn_state: :idle, + crf: 2, + last_reference: now() + }) + + assert [{"2", _unused_conn} | _others] = Connections.get_unused_conns(name) end - test "lower crf and lower reference" do - conns = %{ - "1" => %Conn{ - conn_state: :idle, - crf: 1.99, - last_reference: now() - 1 - }, - "2" => %Conn{ - conn_state: :idle, - crf: 2, - last_reference: now() - } - } + test "lower crf and lower reference", %{name: name} do + Connections.add_conn(name, "1", %Conn{ + conn_state: :idle, + crf: 1.99, + last_reference: now() - 1 + }) - assert [{"1", _unused_conn} | _others] = Connections.get_unused_conns(conns) + Connections.add_conn(name, "2", %Conn{ + conn_state: :idle, + crf: 2, + last_reference: now() + }) + + assert [{"1", _unused_conn} | _others] = Connections.get_unused_conns(name) end end + test "count/1", %{name: name} do + assert Connections.count(name) == 0 + Connections.add_conn(name, "1", %Conn{conn: self()}) + assert Connections.count(name) == 1 + Connections.remove_conn(name, "1") + assert Connections.count(name) == 0 + end + defp now do :os.system_time(:second) end From 6b012ddd69aec0f85c22ad91dbb76e05f2edaf58 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 25 Feb 2020 19:01:29 +0300 Subject: [PATCH 027/188] some docs --- docs/configuration/cheatsheet.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index d99537a50..d5a978c5a 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -394,6 +394,8 @@ For each pool, the options are: Advanced settings for connections pool. Pool with opened connections. These connections can be reused in worker pools. +For big instances it's recommended to increase `max_connections` up to 500-1000. It will increase memory usage, but federation would work faster. + * `:receive_connection_timeout` - timeout to receive connection from pool. Default: 250ms. * `:max_connections` - maximum number of connections in the pool. Default: 250 connections. * `:retry` - number of retries, while `gun` will try to reconnect if connections goes down. Default: 5. From 2622cf1190fe8e6ec9145a8cd2538a56889aa7e2 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 2 Mar 2020 09:22:34 +0300 Subject: [PATCH 028/188] returning repo parameters --- config/config.exs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/config/config.exs b/config/config.exs index 159aa6398..82012dc10 100644 --- a/config/config.exs +++ b/config/config.exs @@ -49,8 +49,7 @@ config :pleroma, Pleroma.Repo, types: Pleroma.PostgresTypes, telemetry_event: [Pleroma.Repo.Instrumenter], - migration_lock: nil, - parameters: [gin_fuzzy_search_limit: "500"] + migration_lock: nil config :pleroma, Pleroma.Captcha, enabled: true, @@ -603,6 +602,8 @@ config :pleroma, configurable_from_database: false +config :pleroma, Pleroma.Repo, parameters: [gin_fuzzy_search_limit: "500"] + config :pleroma, :connections_pool, receive_connection_timeout: 250, max_connections: 250, From 137c600cae9869e706d10b06dea04c9249e043da Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 2 Mar 2020 10:01:07 +0300 Subject: [PATCH 029/188] stop connections manually --- test/pool/connections_test.exs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/pool/connections_test.exs b/test/pool/connections_test.exs index f766e3b5f..0e7a118ab 100644 --- a/test/pool/connections_test.exs +++ b/test/pool/connections_test.exs @@ -23,11 +23,18 @@ defmodule Pleroma.Pool.ConnectionsTest do name = :test_connections adapter = Application.get_env(:tesla, :adapter) Application.put_env(:tesla, :adapter, Tesla.Adapter.Gun) - on_exit(fn -> Application.put_env(:tesla, :adapter, adapter) end) - {:ok, _pid} = + {:ok, pid} = Connections.start_link({name, [max_connections: 2, receive_connection_timeout: 1_500]}) + on_exit(fn -> + Application.put_env(:tesla, :adapter, adapter) + + if Process.alive?(pid) do + GenServer.stop(name) + end + end) + {:ok, name: name} end From 85d571fc238c14bedbc0d9a0af2c7c0d76d62c4a Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 2 Mar 2020 12:52:41 -0600 Subject: [PATCH 030/188] Move Tesla repo to our GitLab --- mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index 017228b4c..5c1d89208 100644 --- a/mix.exs +++ b/mix.exs @@ -121,7 +121,7 @@ defp deps do {:poison, "~> 3.0", override: true}, # {:tesla, "~> 1.3", override: true}, {:tesla, - github: "alex-strizhakov/tesla", + git: "https://git.pleroma.social/pleroma/elixir-libraries/tesla.git", ref: "922cc3db13b421763edbea76246b8ea61c38c6fa", override: true}, {:castore, "~> 0.1"}, diff --git a/mix.lock b/mix.lock index fecc959e0..8b5c61895 100644 --- a/mix.lock +++ b/mix.lock @@ -102,7 +102,7 @@ "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"}, "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"}, - "tesla": {:git, "https://github.com/alex-strizhakov/tesla.git", "922cc3db13b421763edbea76246b8ea61c38c6fa", [ref: "922cc3db13b421763edbea76246b8ea61c38c6fa"]}, + "tesla": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/tesla.git", "922cc3db13b421763edbea76246b8ea61c38c6fa", [ref: "922cc3db13b421763edbea76246b8ea61c38c6fa"]}, "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"}, "trailing_format_plug": {:hex, :trailing_format_plug, "0.0.7", "64b877f912cf7273bed03379936df39894149e35137ac9509117e59866e10e45", [:mix], [{:plug, "> 0.12.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bd4fde4c15f3e993a999e019d64347489b91b7a9096af68b2bdadd192afa693f"}, "tzdata": {:hex, :tzdata, "0.5.22", "f2ba9105117ee0360eae2eca389783ef7db36d533899b2e84559404dbc77ebb8", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "cd66c8a1e6a9e121d1f538b01bef459334bb4029a1ffb4eeeb5e4eae0337e7b6"}, From f987d83885eef7cd8d114feefe8870a8c5e841c6 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 2 Mar 2020 13:00:05 -0600 Subject: [PATCH 031/188] Clarify in docs how to control connections_pool for Gun. It could easily be confused with the Hackney settings. --- docs/configuration/cheatsheet.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 507f15b87..abb5a3c5f 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -395,7 +395,8 @@ For each pool, the options are: Advanced settings for connections pool. Pool with opened connections. These connections can be reused in worker pools. -For big instances it's recommended to increase `max_connections` up to 500-1000. It will increase memory usage, but federation would work faster. +For big instances it's recommended to increase `config :pleroma, :connections_pool, max_connections: 500` up to 500-1000. +It will increase memory usage, but federation would work faster. * `:receive_connection_timeout` - timeout to receive connection from pool. Default: 250ms. * `:max_connections` - maximum number of connections in the pool. Default: 250 connections. From 3ecdead31ae65f395104a5fd7fafc847a7b97eca Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 10:33:40 +0300 Subject: [PATCH 032/188] debug logs on pleroma restart --- restarter/lib/pleroma.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/restarter/lib/pleroma.ex b/restarter/lib/pleroma.ex index 4ade890f9..e48bc4d1d 100644 --- a/restarter/lib/pleroma.ex +++ b/restarter/lib/pleroma.ex @@ -44,7 +44,7 @@ def handle_cast(:need_reboot, state) do end def handle_cast({:restart, :test, _}, state) do - Logger.warn("pleroma manually restarted") + Logger.debug("pleroma manually restarted") {:noreply, Map.put(state, :need_reboot?, false)} end @@ -57,7 +57,7 @@ def handle_cast({:restart, _, delay}, state) do def handle_cast({:after_boot, _}, %{after_boot: true} = state), do: {:noreply, state} def handle_cast({:after_boot, :test}, state) do - Logger.warn("pleroma restarted after boot") + Logger.debug("pleroma restarted after boot") {:noreply, Map.put(state, :after_boot, true)} end From 4c8569d403f47957f7a5d698c595959007c8a95a Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 12:19:29 +0300 Subject: [PATCH 033/188] otp_version refactor --- lib/pleroma/application.ex | 35 +++++----- lib/pleroma/otp_version.ex | 68 +++++++++----------- test/fixtures/warnings/otp_version/error | 1 - test/fixtures/warnings/otp_version/undefined | 1 - test/otp_version_test.exs | 42 ++++-------- 5 files changed, 60 insertions(+), 87 deletions(-) delete mode 100644 test/fixtures/warnings/otp_version/error delete mode 100644 test/fixtures/warnings/otp_version/undefined diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 00e33d7ac..9b228d6b9 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -66,16 +66,23 @@ def start(_type, _args) do Pleroma.Gopher.Server ] - case Pleroma.OTPVersion.check_version() do - :ok -> :ok - {:error, version} -> raise " - !!!OTP VERSION WARNING!!! - You are using gun adapter with OTP version #{version}, which doesn't support correct handling of unordered certificates chains. - " - :undefined -> raise " - !!!OTP VERSION WARNING!!! - To support correct handling of unordered certificates chains - OTP version must be > 22.2. - " + if adapter() == Tesla.Adapter.Gun do + case Pleroma.OTPVersion.check() do + :ok -> + :ok + + {:error, version} -> + raise " + !!!OTP VERSION WARNING!!! + You are using gun adapter with OTP version #{version}, which doesn't support correct handling of unordered certificates chains. + " + + :undefined -> + raise " + !!!OTP VERSION WARNING!!! + To support correct handling of unordered certificates chains - OTP version must be > 22.2. + " + end end # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html @@ -202,11 +209,7 @@ defp http_pools_children(:test) do [hackney_pool, Pleroma.Pool.Supervisor] end - defp http_pools_children(_) do - :tesla - |> Application.get_env(:adapter) - |> http_pools() - end + defp http_pools_children(_), do: http_pools(adapter()) defp http_pools(Tesla.Adapter.Hackney) do pools = [:federation, :media] @@ -227,4 +230,6 @@ defp http_pools(Tesla.Adapter.Hackney) do defp http_pools(Tesla.Adapter.Gun), do: [Pleroma.Pool.Supervisor] defp http_pools(_), do: [] + + defp adapter, do: Application.get_env(:tesla, :adapter) end diff --git a/lib/pleroma/otp_version.ex b/lib/pleroma/otp_version.ex index 0be189304..54ceaff47 100644 --- a/lib/pleroma/otp_version.ex +++ b/lib/pleroma/otp_version.ex @@ -1,63 +1,53 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.OTPVersion do - @type check_status() :: :undefined | {:error, String.t()} | :ok + @type check_status() :: :ok | :undefined | {:error, String.t()} - require Logger - - @spec check_version() :: check_status() - def check_version do + @spec check() :: check_status() + def check do # OTP Version https://erlang.org/doc/system_principles/versions.html#otp-version - paths = [ + [ Path.join(:code.root_dir(), "OTP_VERSION"), Path.join([:code.root_dir(), "releases", :erlang.system_info(:otp_release), "OTP_VERSION"]) ] - - :tesla - |> Application.get_env(:adapter) - |> get_and_check_version(paths) + |> get_version_from_files() + |> do_check() end - @spec get_and_check_version(module(), [Path.t()]) :: check_status() - def get_and_check_version(Tesla.Adapter.Gun, paths) do + @spec check([Path.t()]) :: check_status() + def check(paths) do paths - |> check_files() - |> check_version() + |> get_version_from_files() + |> do_check() end - def get_and_check_version(_, _), do: :ok + defp get_version_from_files([]), do: nil - defp check_files([]), do: nil - - defp check_files([path | paths]) do + defp get_version_from_files([path | paths]) do if File.exists?(path) do File.read!(path) else - check_files(paths) + get_version_from_files(paths) end end - defp check_version(nil), do: :undefined + defp do_check(nil), do: :undefined - defp check_version(version) do - try do - version = String.replace(version, ~r/\r|\n|\s/, "") + defp do_check(version) do + version = String.replace(version, ~r/\r|\n|\s/, "") - formatted = - version - |> String.split(".") - |> Enum.map(&String.to_integer/1) - |> Enum.take(2) + [major, minor] = + version + |> String.split(".") + |> Enum.map(&String.to_integer/1) + |> Enum.take(2) - with [major, minor] when length(formatted) == 2 <- formatted, - true <- (major == 22 and minor >= 2) or major > 22 do - :ok - else - false -> {:error, version} - _ -> :undefined - end - rescue - _ -> :undefined - catch - _ -> :undefined + if (major == 22 and minor >= 2) or major > 22 do + :ok + else + {:error, version} end end end diff --git a/test/fixtures/warnings/otp_version/error b/test/fixtures/warnings/otp_version/error deleted file mode 100644 index 8fdd954df..000000000 --- a/test/fixtures/warnings/otp_version/error +++ /dev/null @@ -1 +0,0 @@ -22 \ No newline at end of file diff --git a/test/fixtures/warnings/otp_version/undefined b/test/fixtures/warnings/otp_version/undefined deleted file mode 100644 index 66dc9051d..000000000 --- a/test/fixtures/warnings/otp_version/undefined +++ /dev/null @@ -1 +0,0 @@ -undefined \ No newline at end of file diff --git a/test/otp_version_test.exs b/test/otp_version_test.exs index f26b90f61..af278cc72 100644 --- a/test/otp_version_test.exs +++ b/test/otp_version_test.exs @@ -1,58 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.OTPVersionTest do use ExUnit.Case, async: true alias Pleroma.OTPVersion - describe "get_and_check_version/2" do + describe "check/1" do test "22.4" do - assert OTPVersion.get_and_check_version(Tesla.Adapter.Gun, [ - "test/fixtures/warnings/otp_version/22.4" - ]) == :ok + assert OTPVersion.check(["test/fixtures/warnings/otp_version/22.4"]) == :ok end test "22.1" do - assert OTPVersion.get_and_check_version(Tesla.Adapter.Gun, [ - "test/fixtures/warnings/otp_version/22.1" - ]) == {:error, "22.1"} + assert OTPVersion.check(["test/fixtures/warnings/otp_version/22.1"]) == {:error, "22.1"} end test "21.1" do - assert OTPVersion.get_and_check_version(Tesla.Adapter.Gun, [ - "test/fixtures/warnings/otp_version/21.1" - ]) == {:error, "21.1"} + assert OTPVersion.check(["test/fixtures/warnings/otp_version/21.1"]) == {:error, "21.1"} end test "23.0" do - assert OTPVersion.get_and_check_version(Tesla.Adapter.Gun, [ - "test/fixtures/warnings/otp_version/23.0" - ]) == :ok - end - - test "undefined" do - assert OTPVersion.get_and_check_version(Tesla.Adapter.Gun, [ - "test/fixtures/warnings/otp_version/undefined" - ]) == :undefined - end - - test "not parsable" do - assert OTPVersion.get_and_check_version(Tesla.Adapter.Gun, [ - "test/fixtures/warnings/otp_version/error" - ]) == :undefined + assert OTPVersion.check(["test/fixtures/warnings/otp_version/23.0"]) == :ok end test "with non existance file" do - assert OTPVersion.get_and_check_version(Tesla.Adapter.Gun, [ + assert OTPVersion.check([ "test/fixtures/warnings/otp_version/non-exising", "test/fixtures/warnings/otp_version/22.4" ]) == :ok end test "empty paths" do - assert OTPVersion.get_and_check_version(Tesla.Adapter.Gun, []) == :undefined - end - - test "another adapter" do - assert OTPVersion.get_and_check_version(Tesla.Adapter.Hackney, []) == :ok + assert OTPVersion.check([]) == :undefined end end end From 097ad10d02598fb6b77f305c10341a13fb57ceee Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 09:29:51 +0000 Subject: [PATCH 034/188] Apply suggestion to lib/pleroma/pool/connections.ex --- lib/pleroma/pool/connections.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/pool/connections.ex b/lib/pleroma/pool/connections.ex index a444f822f..c5098cd86 100644 --- a/lib/pleroma/pool/connections.ex +++ b/lib/pleroma/pool/connections.ex @@ -128,7 +128,7 @@ def handle_call({:checkin, uri}, from, state) do Logger.debug("checkin #{key}") case state.conns[key] do - %{conn: conn, gun_state: gun_state} = current_conn when gun_state == :up -> + %{conn: conn, gun_state: :up} = current_conn -> Logger.debug("reusing conn #{key}") with time <- :os.system_time(:second), From 2c8d80dc0ad594cfe25ebadd9e7a187c95914b34 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 09:29:57 +0000 Subject: [PATCH 035/188] Apply suggestion to lib/pleroma/pool/connections.ex --- lib/pleroma/pool/connections.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/pool/connections.ex b/lib/pleroma/pool/connections.ex index c5098cd86..c4c5fd66c 100644 --- a/lib/pleroma/pool/connections.ex +++ b/lib/pleroma/pool/connections.ex @@ -145,7 +145,7 @@ def handle_call({:checkin, uri}, from, state) do {:reply, conn, state} end - %{gun_state: gun_state} when gun_state == :down -> + %{gun_state: :down} -> {:reply, nil, state} nil -> From a3ad028973154dafad910d4d73d7d4d4822627c1 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 09:34:36 +0000 Subject: [PATCH 036/188] Apply suggestion to lib/pleroma/http/adapter.ex --- lib/pleroma/http/adapter.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/http/adapter.ex b/lib/pleroma/http/adapter.ex index 6166a3eb4..32046b1d3 100644 --- a/lib/pleroma/http/adapter.ex +++ b/lib/pleroma/http/adapter.ex @@ -57,7 +57,7 @@ def domain_or_ip(host) do {:error, :einval} -> {:domain, :idna.encode(charlist)} - {:ok, ip} when is_tuple(ip) and tuple_size(ip) in [4, 8] -> + {:ok, ip} -> {:ip, ip} end end From df3c59d9280b94cf99571cbbd1b10c334db8e44d Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 09:45:18 +0000 Subject: [PATCH 037/188] Apply suggestion to docs/configuration/cheatsheet.md --- docs/configuration/cheatsheet.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index f735b19b8..65f37e846 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -416,7 +416,7 @@ It will increase memory usage, but federation would work faster. Advanced settings for workers pools. -There's four pools used: +There are four pools used: * `:federation` for the federation jobs. You may want this pool max_connections to be at least equal to the number of federator jobs + retry queue jobs. From d30ff35d94ff7d8bc07f0221323a75b07641ee8d Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 09:46:53 +0000 Subject: [PATCH 038/188] Apply suggestion to lib/pleroma/http/request_builder.ex --- lib/pleroma/http/request_builder.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/http/request_builder.ex b/lib/pleroma/http/request_builder.ex index 491acd0f9..046741d99 100644 --- a/lib/pleroma/http/request_builder.ex +++ b/lib/pleroma/http/request_builder.ex @@ -35,7 +35,7 @@ def url(request, u), do: %{request | url: u} def headers(request, headers) do headers_list = if Pleroma.Config.get([:http, :send_user_agent]) do - headers ++ [{"user-agent", Pleroma.Application.user_agent()}] + [{"user-agent", Pleroma.Application.user_agent()} | headers] else headers end From 614e3934f9190ff199df087de34146ad5f34c660 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 09:50:42 +0000 Subject: [PATCH 039/188] Apply suggestion to lib/pleroma/http/http.ex --- lib/pleroma/http/http.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/http/http.ex b/lib/pleroma/http/http.ex index ad47dc936..5fb468689 100644 --- a/lib/pleroma/http/http.ex +++ b/lib/pleroma/http/http.ex @@ -64,8 +64,8 @@ def request(method, url, body, headers, options) when is_binary(url) do client <- Tesla.client([Tesla.Middleware.FollowRedirects], tesla_adapter()), pid <- Process.whereis(adapter_opts[:pool]) do pool_alive? = - if tesla_adapter() == Tesla.Adapter.Gun do - if pid, do: Process.alive?(pid), else: false + if tesla_adapter() == Tesla.Adapter.Gun && pid do + Process.alive?(pid) else false end From a21a66972f8733de766bc538fe81f2e0ccb57925 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 09:52:01 +0000 Subject: [PATCH 040/188] Apply suggestion to lib/pleroma/http/http.ex --- lib/pleroma/http/http.ex | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/pleroma/http/http.ex b/lib/pleroma/http/http.ex index 5fb468689..0235f89ea 100644 --- a/lib/pleroma/http/http.ex +++ b/lib/pleroma/http/http.ex @@ -76,12 +76,7 @@ def request(method, url, body, headers, options) when is_binary(url) do |> Map.put(:env, Pleroma.Config.get([:env])) |> Map.put(:pool_alive?, pool_alive?) - response = - request( - client, - request, - request_opts - ) + response = request(client, request, request_opts) Connection.after_request(adapter_opts) From 7eb65929924af50146d89192c2cf557e3bdbf07f Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 09:53:31 +0000 Subject: [PATCH 041/188] Apply suggestion to lib/pleroma/pool/connections.ex --- lib/pleroma/pool/connections.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/pool/connections.ex b/lib/pleroma/pool/connections.ex index c4c5fd66c..84617815f 100644 --- a/lib/pleroma/pool/connections.ex +++ b/lib/pleroma/pool/connections.ex @@ -180,10 +180,10 @@ def handle_info({:gun_up, conn_pid, _protocol}, state) do state = with conn_key when is_binary(conn_key) <- compose_key_gun_info(conn_pid), {key, conn} <- find_conn(state.conns, conn_pid, conn_key), - {true, key} <- {Process.alive?(conn_pid), key}, - time <- :os.system_time(:second), - last_reference <- time - conn.last_reference, - current_crf <- crf(last_reference, 100, conn.crf) do + {true, key} <- {Process.alive?(conn_pid), key} do + time = :os.system_time(:second) + last_reference = time - conn.last_reference + current_crf = crf(last_reference, 100, conn.crf) put_in(state.conns[key], %{ conn | gun_state: :up, From 151dc4e387cfbb91b7cd85461ce0deb1e5f5fe30 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 09:53:37 +0000 Subject: [PATCH 042/188] Apply suggestion to lib/pleroma/reverse_proxy/client/tesla.ex --- lib/pleroma/reverse_proxy/client/tesla.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/reverse_proxy/client/tesla.ex b/lib/pleroma/reverse_proxy/client/tesla.ex index 55a11b4a8..498a905e1 100644 --- a/lib/pleroma/reverse_proxy/client/tesla.ex +++ b/lib/pleroma/reverse_proxy/client/tesla.ex @@ -16,7 +16,7 @@ defmodule Pleroma.ReverseProxy.Client.Tesla do @impl true def request(method, url, headers, body, opts \\ []) do - _adapter = check_adapter() + check_adapter() with opts <- Keyword.merge(opts, body_as: :chunks, mode: :passive), {:ok, response} <- From 28ed4b41d03c6a137d198b8c67fb081c7ebfbbc6 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 13:05:28 +0300 Subject: [PATCH 043/188] naming for checkin from pool timeout --- config/config.exs | 2 +- docs/configuration/cheatsheet.md | 2 +- lib/pleroma/pool/connections.ex | 3 ++- test/pool/connections_test.exs | 3 +-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/config/config.exs b/config/config.exs index 7c94a0f26..661dfad20 100644 --- a/config/config.exs +++ b/config/config.exs @@ -607,7 +607,7 @@ prepare: :unnamed config :pleroma, :connections_pool, - receive_connection_timeout: 250, + checkin_timeout: 250, max_connections: 250, retry: 0, retry_timeout: 100, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 65f37e846..ef3cc40e6 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -404,7 +404,7 @@ Advanced settings for connections pool. Pool with opened connections. These conn For big instances it's recommended to increase `config :pleroma, :connections_pool, max_connections: 500` up to 500-1000. It will increase memory usage, but federation would work faster. -* `:receive_connection_timeout` - timeout to receive connection from pool. Default: 250ms. +* `:checkin_timeout` - timeout to checkin connection from pool. Default: 250ms. * `:max_connections` - maximum number of connections in the pool. Default: 250 connections. * `:retry` - number of retries, while `gun` will try to reconnect if connections goes down. Default: 5. * `:retry_timeout` - timeout while `gun` will try to reconnect. Default: 100ms. diff --git a/lib/pleroma/pool/connections.ex b/lib/pleroma/pool/connections.ex index 84617815f..05fa8f7ad 100644 --- a/lib/pleroma/pool/connections.ex +++ b/lib/pleroma/pool/connections.ex @@ -34,7 +34,7 @@ def checkin(url, name) def checkin(url, name) when is_binary(url), do: checkin(URI.parse(url), name) def checkin(%URI{} = uri, name) do - timeout = Config.get([:connections_pool, :receive_connection_timeout], 250) + timeout = Config.get([:connections_pool, :checkin_timeout], 250) GenServer.call( name, @@ -184,6 +184,7 @@ def handle_info({:gun_up, conn_pid, _protocol}, state) do time = :os.system_time(:second) last_reference = time - conn.last_reference current_crf = crf(last_reference, 100, conn.crf) + put_in(state.conns[key], %{ conn | gun_state: :up, diff --git a/test/pool/connections_test.exs b/test/pool/connections_test.exs index 0e7a118ab..a084f31b9 100644 --- a/test/pool/connections_test.exs +++ b/test/pool/connections_test.exs @@ -24,8 +24,7 @@ defmodule Pleroma.Pool.ConnectionsTest do adapter = Application.get_env(:tesla, :adapter) Application.put_env(:tesla, :adapter, Tesla.Adapter.Gun) - {:ok, pid} = - Connections.start_link({name, [max_connections: 2, receive_connection_timeout: 1_500]}) + {:ok, pid} = Connections.start_link({name, [max_connections: 2, checkin_timeout: 1_500]}) on_exit(fn -> Application.put_env(:tesla, :adapter, adapter) From 24d1ac125c6ae719b3d119f2ec0079dcd74eadc2 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 13:24:19 +0300 Subject: [PATCH 044/188] hiding raise error logic to otp_version module --- lib/pleroma/application.ex | 23 ++++------------------- lib/pleroma/otp_version.ex | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 9b228d6b9..d0b9c3c41 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -42,6 +42,10 @@ def start(_type, _args) do setup_instrumenters() load_custom_modules() + if adapter() == Tesla.Adapter.Gun do + Pleroma.OTPVersion.check!() + end + # Define workers and child supervisors to be supervised children = [ @@ -66,25 +70,6 @@ def start(_type, _args) do Pleroma.Gopher.Server ] - if adapter() == Tesla.Adapter.Gun do - case Pleroma.OTPVersion.check() do - :ok -> - :ok - - {:error, version} -> - raise " - !!!OTP VERSION WARNING!!! - You are using gun adapter with OTP version #{version}, which doesn't support correct handling of unordered certificates chains. - " - - :undefined -> - raise " - !!!OTP VERSION WARNING!!! - To support correct handling of unordered certificates chains - OTP version must be > 22.2. - " - end - end - # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: Pleroma.Supervisor] diff --git a/lib/pleroma/otp_version.ex b/lib/pleroma/otp_version.ex index 54ceaff47..9ced2d27d 100644 --- a/lib/pleroma/otp_version.ex +++ b/lib/pleroma/otp_version.ex @@ -5,6 +5,26 @@ defmodule Pleroma.OTPVersion do @type check_status() :: :ok | :undefined | {:error, String.t()} + @spec check!() :: :ok | no_return() + def check! do + case check() do + :ok -> + :ok + + {:error, version} -> + raise " + !!!OTP VERSION WARNING!!! + You are using gun adapter with OTP version #{version}, which doesn't support correct handling of unordered certificates chains. + " + + :undefined -> + raise " + !!!OTP VERSION WARNING!!! + To support correct handling of unordered certificates chains - OTP version must be > 22.2. + " + end + end + @spec check() :: check_status() def check do # OTP Version https://erlang.org/doc/system_principles/versions.html#otp-version From d0e4d3ca3b9d8b8ed00d58e9e1c2a05ab561326c Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 14:56:49 +0300 Subject: [PATCH 045/188] removing unnecessary with comment in tesla client impovement --- lib/pleroma/pool/connections.ex | 40 +++++++++++------------ lib/pleroma/reverse_proxy/client/tesla.ex | 8 +++-- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/lib/pleroma/pool/connections.ex b/lib/pleroma/pool/connections.ex index 05fa8f7ad..bde3ffd13 100644 --- a/lib/pleroma/pool/connections.ex +++ b/lib/pleroma/pool/connections.ex @@ -36,17 +36,16 @@ def checkin(url, name) when is_binary(url), do: checkin(URI.parse(url), name) def checkin(%URI{} = uri, name) do timeout = Config.get([:connections_pool, :checkin_timeout], 250) - GenServer.call( - name, - {:checkin, uri}, - timeout - ) + GenServer.call(name, {:checkin, uri}, timeout) end @spec alive?(atom()) :: boolean() def alive?(name) do - pid = Process.whereis(name) - if pid, do: Process.alive?(pid), else: false + if pid = Process.whereis(name) do + Process.alive?(pid) + else + false + end end @spec get_state(atom()) :: t() @@ -131,19 +130,20 @@ def handle_call({:checkin, uri}, from, state) do %{conn: conn, gun_state: :up} = current_conn -> Logger.debug("reusing conn #{key}") - with time <- :os.system_time(:second), - last_reference <- time - current_conn.last_reference, - current_crf <- crf(last_reference, 100, current_conn.crf), - state <- - put_in(state.conns[key], %{ - current_conn - | last_reference: time, - crf: current_crf, - conn_state: :active, - used_by: [from | current_conn.used_by] - }) do - {:reply, conn, state} - end + time = :os.system_time(:second) + last_reference = time - current_conn.last_reference + current_crf = crf(last_reference, 100, current_conn.crf) + + state = + put_in(state.conns[key], %{ + current_conn + | last_reference: time, + crf: current_crf, + conn_state: :active, + used_by: [from | current_conn.used_by] + }) + + {:reply, conn, state} %{gun_state: :down} -> {:reply, nil, state} diff --git a/lib/pleroma/reverse_proxy/client/tesla.ex b/lib/pleroma/reverse_proxy/client/tesla.ex index 498a905e1..80a0c8972 100644 --- a/lib/pleroma/reverse_proxy/client/tesla.ex +++ b/lib/pleroma/reverse_proxy/client/tesla.ex @@ -18,8 +18,9 @@ defmodule Pleroma.ReverseProxy.Client.Tesla do def request(method, url, headers, body, opts \\ []) do check_adapter() - with opts <- Keyword.merge(opts, body_as: :chunks, mode: :passive), - {:ok, response} <- + opts = Keyword.merge(opts, body_as: :chunks) + + with {:ok, response} <- Pleroma.HTTP.request( method, url, @@ -40,7 +41,8 @@ def request(method, url, headers, body, opts \\ []) do @impl true @spec stream_body(map()) :: {:ok, binary(), map()} | {:error, atom() | String.t()} | :done def stream_body(%{pid: pid, opts: opts, fin: true}) do - # if connection was sended and there were redirects, we need to close new conn - pid manually + # if connection was reused, but in tesla were redirects, + # tesla returns new opened connection, which must be closed manually if opts[:old_conn], do: Tesla.Adapter.Gun.close(pid) # if there were redirects we need to checkout old conn conn = opts[:old_conn] || opts[:conn] From 05429730e46b8605544637feebd4c409a4e9ed18 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 15:11:48 +0300 Subject: [PATCH 046/188] unnecessary with --- lib/pleroma/http/http.ex | 51 ++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/lib/pleroma/http/http.ex b/lib/pleroma/http/http.ex index 0235f89ea..f7b0095d7 100644 --- a/lib/pleroma/http/http.ex +++ b/lib/pleroma/http/http.ex @@ -55,33 +55,36 @@ def post(url, body, headers \\ [], options \\ []), @spec request(atom(), Request.url(), String.t(), Request.headers(), keyword()) :: {:ok, Env.t()} | {:error, any()} def request(method, url, body, headers, options) when is_binary(url) do - with uri <- URI.parse(url), - received_adapter_opts <- Keyword.get(options, :adapter, []), - adapter_opts <- Connection.options(uri, received_adapter_opts), - options <- put_in(options[:adapter], adapter_opts), - params <- Keyword.get(options, :params, []), - request <- build_request(method, headers, options, url, body, params), - client <- Tesla.client([Tesla.Middleware.FollowRedirects], tesla_adapter()), - pid <- Process.whereis(adapter_opts[:pool]) do - pool_alive? = - if tesla_adapter() == Tesla.Adapter.Gun && pid do - Process.alive?(pid) - else - false - end + uri = URI.parse(url) + received_adapter_opts = Keyword.get(options, :adapter, []) + adapter_opts = Connection.options(uri, received_adapter_opts) + options = put_in(options[:adapter], adapter_opts) + params = Keyword.get(options, :params, []) + request = build_request(method, headers, options, url, body, params) - request_opts = - adapter_opts - |> Enum.into(%{}) - |> Map.put(:env, Pleroma.Config.get([:env])) - |> Map.put(:pool_alive?, pool_alive?) + adapter = Application.get_env(:tesla, :adapter) + client = Tesla.client([Tesla.Middleware.FollowRedirects], adapter) - response = request(client, request, request_opts) + pid = Process.whereis(adapter_opts[:pool]) - Connection.after_request(adapter_opts) + pool_alive? = + if adapter == Tesla.Adapter.Gun && pid do + Process.alive?(pid) + else + false + end - response - end + request_opts = + adapter_opts + |> Enum.into(%{}) + |> Map.put(:env, Pleroma.Config.get([:env])) + |> Map.put(:pool_alive?, pool_alive?) + + response = request(client, request, request_opts) + + Connection.after_request(adapter_opts) + + response end @spec request(Client.t(), keyword(), map()) :: {:ok, Env.t()} | {:error, any()} @@ -138,6 +141,4 @@ defp build_request(method, headers, options, url, body, params) do |> Builder.add_param(:query, :query, params) |> Builder.convert_to_keyword() end - - defp tesla_adapter, do: Application.get_env(:tesla, :adapter) end From ee8071f0d5a8a53f6a9ae635d6ea57ce8576e21b Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 15:12:09 +0300 Subject: [PATCH 047/188] removing unused method --- lib/pleroma/http/request_builder.ex | 20 -------------------- test/http/request_builder_test.exs | 17 ----------------- 2 files changed, 37 deletions(-) diff --git a/lib/pleroma/http/request_builder.ex b/lib/pleroma/http/request_builder.ex index 046741d99..5b92ce764 100644 --- a/lib/pleroma/http/request_builder.ex +++ b/lib/pleroma/http/request_builder.ex @@ -49,26 +49,6 @@ def headers(request, headers) do @spec opts(Request.t(), keyword()) :: Request.t() def opts(request, options), do: %{request | opts: options} - # NOTE: isn't used anywhere - @doc """ - Add optional parameters to the request - - """ - @spec add_optional_params(Request.t(), %{optional(atom) => atom}, keyword()) :: map() - def add_optional_params(request, _, []), do: request - - def add_optional_params(request, definitions, [{key, value} | tail]) do - case definitions do - %{^key => location} -> - request - |> add_param(location, key, value) - |> add_optional_params(definitions, tail) - - _ -> - add_optional_params(request, definitions, tail) - end - end - @doc """ Add optional parameters to the request """ diff --git a/test/http/request_builder_test.exs b/test/http/request_builder_test.exs index f87ca11d3..f6eeac6c0 100644 --- a/test/http/request_builder_test.exs +++ b/test/http/request_builder_test.exs @@ -36,23 +36,6 @@ test "send custom user agent" do end end - describe "add_optional_params/3" do - test "don't add if keyword is empty" do - assert RequestBuilder.add_optional_params(%{}, %{}, []) == %{} - end - - test "add query parameter" do - assert RequestBuilder.add_optional_params( - %Request{}, - %{query: :query, body: :body, another: :val}, - [ - {:query, "param1=val1¶m2=val2"}, - {:body, "some body"} - ] - ) == %Request{query: "param1=val1¶m2=val2", body: "some body"} - end - end - describe "add_param/4" do test "add file parameter" do %Request{ From e605e79df9761cef3d9f93c489dd4618c6b70eda Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 15:44:13 +0300 Subject: [PATCH 048/188] simplification of formatting host method case for format_proxy method --- lib/pleroma/gun/conn.ex | 6 ++--- lib/pleroma/http/adapter.ex | 29 +++--------------------- lib/pleroma/http/adapter/gun.ex | 20 +++++++++++++---- test/http/adapter/gun_test.exs | 21 ++++++++++++++++- test/http/adapter_test.exs | 40 +-------------------------------- 5 files changed, 43 insertions(+), 73 deletions(-) diff --git a/lib/pleroma/gun/conn.ex b/lib/pleroma/gun/conn.ex index ddb9f30b0..a33d75558 100644 --- a/lib/pleroma/gun/conn.ex +++ b/lib/pleroma/gun/conn.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Gun.Conn do @@ -131,7 +131,7 @@ defp do_open(uri, %{proxy: {proxy_type, proxy_host, proxy_port}} = opts) do end defp do_open(%URI{host: host, port: port} = uri, opts) do - {_type, host} = Pleroma.HTTP.Adapter.domain_or_ip(host) + host = Pleroma.HTTP.Connection.parse_host(host) with {:ok, conn} <- API.open(host, port, opts), {:ok, _} <- API.await_up(conn, opts[:await_up_timeout]) do @@ -149,7 +149,7 @@ defp do_open(%URI{host: host, port: port} = uri, opts) do end defp destination_opts(%URI{host: host, port: port}) do - {_type, host} = Pleroma.HTTP.Adapter.domain_or_ip(host) + host = Pleroma.HTTP.Connection.parse_host(host) %{host: host, port: port} end diff --git a/lib/pleroma/http/adapter.ex b/lib/pleroma/http/adapter.ex index 32046b1d3..a3b84d8f3 100644 --- a/lib/pleroma/http/adapter.ex +++ b/lib/pleroma/http/adapter.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.HTTP.Adapter do @@ -8,7 +8,6 @@ defmodule Pleroma.HTTP.Adapter do @type proxy :: {Connection.host(), pos_integer()} | {Connection.proxy_type(), pos_integer()} - @type host_type :: :domain | :ip @callback options(keyword(), URI.t()) :: keyword() @callback after_request(keyword()) :: :ok @@ -29,9 +28,8 @@ def after_request(_opts), do: :ok def format_proxy(nil), do: nil def format_proxy(proxy_url) do - with {:ok, host, port} <- Connection.parse_proxy(proxy_url) do - {host, port} - else + case Connection.parse_proxy(proxy_url) do + {:ok, host, port} -> {host, port} {:ok, type, host, port} -> {type, host, port} _ -> nil end @@ -40,25 +38,4 @@ def format_proxy(proxy_url) do @spec maybe_add_proxy(keyword(), proxy() | nil) :: keyword() def maybe_add_proxy(opts, nil), do: opts def maybe_add_proxy(opts, proxy), do: Keyword.put_new(opts, :proxy, proxy) - - @spec domain_or_fallback(String.t()) :: charlist() - def domain_or_fallback(host) do - case domain_or_ip(host) do - {:domain, domain} -> domain - {:ip, _ip} -> to_charlist(host) - end - end - - @spec domain_or_ip(String.t()) :: {host_type(), Connection.host()} - def domain_or_ip(host) do - charlist = to_charlist(host) - - case :inet.parse_address(charlist) do - {:error, :einval} -> - {:domain, :idna.encode(charlist)} - - {:ok, ip} -> - {:ip, ip} - end - end end diff --git a/lib/pleroma/http/adapter/gun.ex b/lib/pleroma/http/adapter/gun.ex index 908d71898..5e88786bd 100644 --- a/lib/pleroma/http/adapter/gun.ex +++ b/lib/pleroma/http/adapter/gun.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.HTTP.Adapter.Gun do @@ -42,7 +42,7 @@ def after_request(opts) do end defp add_original(opts, %URI{host: host, port: port}) do - formatted_host = Adapter.domain_or_fallback(host) + formatted_host = format_host(host) Keyword.put(opts, :original, "#{formatted_host}:#{port}") end @@ -57,8 +57,7 @@ defp add_scheme_opts(opts, %URI{scheme: "https", host: host, port: port}) do cacertfile: CAStore.file_path(), depth: 20, reuse_sessions: false, - verify_fun: - {&:ssl_verify_hostname.verify_fun/3, [check_hostname: Adapter.domain_or_fallback(host)]}, + verify_fun: {&:ssl_verify_hostname.verify_fun/3, [check_hostname: format_host(host)]}, log_level: :warning ] ] @@ -139,4 +138,17 @@ defp try_to_get_conn(uri, opts) do opts end end + + @spec format_host(String.t()) :: charlist() + def format_host(host) do + host_charlist = to_charlist(host) + + case :inet.parse_address(host_charlist) do + {:error, :einval} -> + :idna.encode(host_charlist) + + {:ok, _ip} -> + host_charlist + end + end end diff --git a/test/http/adapter/gun_test.exs b/test/http/adapter/gun_test.exs index a8dcbae04..a05471ac6 100644 --- a/test/http/adapter/gun_test.exs +++ b/test/http/adapter/gun_test.exs @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.HTTP.Adapter.GunTest do @@ -264,4 +264,23 @@ test "with ipv6" do } = Connections.get_state(:gun_connections) end end + + describe "format_host/1" do + test "with domain" do + assert Gun.format_host("example.com") == 'example.com' + end + + test "with idna domain" do + assert Gun.format_host("ですexample.com") == 'xn--example-183fne.com' + end + + test "with ipv4" do + assert Gun.format_host("127.0.0.1") == '127.0.0.1' + end + + test "with ipv6" do + assert Gun.format_host("2a03:2880:f10c:83:face:b00c:0:25de") == + '2a03:2880:f10c:83:face:b00c:0:25de' + end + end end diff --git a/test/http/adapter_test.exs b/test/http/adapter_test.exs index 37e47dabe..4c805837c 100644 --- a/test/http/adapter_test.exs +++ b/test/http/adapter_test.exs @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.HTTP.AdapterTest do @@ -7,44 +7,6 @@ defmodule Pleroma.HTTP.AdapterTest do alias Pleroma.HTTP.Adapter - describe "domain_or_ip/1" do - test "with domain" do - assert Adapter.domain_or_ip("example.com") == {:domain, 'example.com'} - end - - test "with idna domain" do - assert Adapter.domain_or_ip("ですexample.com") == {:domain, 'xn--example-183fne.com'} - end - - test "with ipv4" do - assert Adapter.domain_or_ip("127.0.0.1") == {:ip, {127, 0, 0, 1}} - end - - test "with ipv6" do - assert Adapter.domain_or_ip("2a03:2880:f10c:83:face:b00c:0:25de") == - {:ip, {10_755, 10_368, 61_708, 131, 64_206, 45_068, 0, 9_694}} - end - end - - describe "domain_or_fallback/1" do - test "with domain" do - assert Adapter.domain_or_fallback("example.com") == 'example.com' - end - - test "with idna domain" do - assert Adapter.domain_or_fallback("ですexample.com") == 'xn--example-183fne.com' - end - - test "with ipv4" do - assert Adapter.domain_or_fallback("127.0.0.1") == '127.0.0.1' - end - - test "with ipv6" do - assert Adapter.domain_or_fallback("2a03:2880:f10c:83:face:b00c:0:25de") == - '2a03:2880:f10c:83:face:b00c:0:25de' - end - end - describe "format_proxy/1" do test "with nil" do assert Adapter.format_proxy(nil) == nil From 7d68924e4f7233590457aa7e32a21f082dd0584f Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 16:08:21 +0300 Subject: [PATCH 049/188] naming --- lib/pleroma/gun/conn.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/gun/conn.ex b/lib/pleroma/gun/conn.ex index a33d75558..a8b8c92c1 100644 --- a/lib/pleroma/gun/conn.ex +++ b/lib/pleroma/gun/conn.ex @@ -54,7 +54,7 @@ def open(%URI{} = uri, name, opts) do if Connections.count(name) < opts[:max_connection] do do_open(uri, opts) else - try_do_open(name, uri, opts) + close_least_used_and_do_open(name, uri, opts) end if is_pid(conn_pid) do @@ -159,7 +159,7 @@ defp add_http2_opts(opts, "https", tls_opts) do defp add_http2_opts(opts, _, _), do: opts - defp try_do_open(name, uri, opts) do + defp close_least_used_and_do_open(name, uri, opts) do Logger.debug("try to open conn #{Connections.compose_uri_log(uri)}") with [{close_key, least_used} | _conns] <- From 8fc00b7cbff86885ec99d01821c403a766202659 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 16:27:46 +0300 Subject: [PATCH 050/188] return error if connection failed to open --- lib/pleroma/gun/conn.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/gun/conn.ex b/lib/pleroma/gun/conn.ex index a8b8c92c1..9ae419092 100644 --- a/lib/pleroma/gun/conn.ex +++ b/lib/pleroma/gun/conn.ex @@ -90,7 +90,7 @@ defp do_open(uri, %{proxy: {proxy_host, proxy_port}} = opts) do } #{inspect(error)}" ) - nil + error end end @@ -126,7 +126,7 @@ defp do_open(uri, %{proxy: {proxy_type, proxy_host, proxy_port}} = opts) do } #{inspect(error)}" ) - nil + error end end @@ -144,7 +144,7 @@ defp do_open(%URI{host: host, port: port} = uri, opts) do }" ) - nil + error end end @@ -169,7 +169,7 @@ defp close_least_used_and_do_open(name, uri, opts) do do_open(uri, opts) else - [] -> nil + [] -> {:error, :pool_overflowed} end end end From 7c0ed9302cb13ab44c1bf18017538315dcd0ce2e Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 16:46:20 +0300 Subject: [PATCH 051/188] unnecessary mock --- test/notification_test.exs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/test/notification_test.exs b/test/notification_test.exs index 1c60f6866..56a581810 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -649,13 +649,6 @@ test "notifications are deleted if a remote user is deleted" do "object" => remote_user.ap_id } - remote_user_url = remote_user.ap_id - - Tesla.Mock.mock(fn - %{method: :get, url: ^remote_user_url} -> - %Tesla.Env{status: 404, body: ""} - end) - {:ok, _delete_activity} = Transmogrifier.handle_incoming(delete_user_message) ObanHelpers.perform_all() From 6ebf389d6e6ca5f3e56f9b017531f5f7e301ed3c Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 16:51:49 +0300 Subject: [PATCH 052/188] poolboy timeout fix --- lib/pleroma/http/http.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/http/http.ex b/lib/pleroma/http/http.ex index f7b0095d7..4b774472e 100644 --- a/lib/pleroma/http/http.ex +++ b/lib/pleroma/http/http.ex @@ -102,8 +102,8 @@ def request(%Client{} = client, request, %{pool: pool, timeout: timeout}) do try do :poolboy.transaction( pool, - &Pleroma.Pool.Request.execute(&1, client, request, timeout + 500), - timeout + 1_000 + &Pleroma.Pool.Request.execute(&1, client, request, timeout), + timeout ) rescue e -> From aaa879ce75a62e69a458226e65bef31b0f2ed08c Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 17:27:22 +0300 Subject: [PATCH 053/188] proxy parsing errors --- lib/pleroma/http/connection.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/http/connection.ex b/lib/pleroma/http/connection.ex index e2d7afbbd..bdd062929 100644 --- a/lib/pleroma/http/connection.ex +++ b/lib/pleroma/http/connection.ex @@ -71,15 +71,15 @@ def parse_proxy(proxy) when is_binary(proxy) do else {_, _} -> Logger.warn("parsing port in proxy fail #{inspect(proxy)}") - {:error, :error_parsing_port_in_proxy} + {:error, :invalid_proxy_port} :error -> Logger.warn("parsing port in proxy fail #{inspect(proxy)}") - {:error, :error_parsing_port_in_proxy} + {:error, :invalid_proxy_port} _ -> Logger.warn("parsing proxy fail #{inspect(proxy)}") - {:error, :error_parsing_proxy} + {:error, :invalid_proxy} end end @@ -89,7 +89,7 @@ def parse_proxy(proxy) when is_tuple(proxy) do else _ -> Logger.warn("parsing proxy fail #{inspect(proxy)}") - {:error, :error_parsing_proxy} + {:error, :invalid_proxy} end end From 24bf5c4e89e6f97ed3d53157cead48c04015a51b Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 17:27:56 +0300 Subject: [PATCH 054/188] remove try block from pool request --- lib/pleroma/http/http.ex | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/lib/pleroma/http/http.ex b/lib/pleroma/http/http.ex index 4b774472e..cc0c39400 100644 --- a/lib/pleroma/http/http.ex +++ b/lib/pleroma/http/http.ex @@ -99,23 +99,11 @@ def request(%Client{} = client, request, %{pool_alive?: false}) do end def request(%Client{} = client, request, %{pool: pool, timeout: timeout}) do - try do - :poolboy.transaction( - pool, - &Pleroma.Pool.Request.execute(&1, client, request, timeout), - timeout - ) - rescue - e -> - {:error, e} - catch - :exit, {:timeout, _} -> - Logger.warn("Receive response from pool failed #{request[:url]}") - {:error, :recv_pool_timeout} - - :exit, e -> - {:error, e} - end + :poolboy.transaction( + pool, + &Pleroma.Pool.Request.execute(&1, client, request, timeout), + timeout + ) end @spec request_try(Client.t(), keyword()) :: {:ok, Env.t()} | {:error, any()} From 3723d723652b747b00fc26054101c15e39a5af18 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 17:32:59 +0300 Subject: [PATCH 055/188] proxy parse tests fix --- test/http/connection_test.exs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/http/connection_test.exs b/test/http/connection_test.exs index 53ccbc9cd..37de11e7a 100644 --- a/test/http/connection_test.exs +++ b/test/http/connection_test.exs @@ -51,31 +51,31 @@ test "as tuple with string host" do describe "parse_proxy/1 errors" do test "ip without port" do capture_log(fn -> - assert Connection.parse_proxy("127.0.0.1") == {:error, :error_parsing_proxy} + assert Connection.parse_proxy("127.0.0.1") == {:error, :invalid_proxy} end) =~ "parsing proxy fail \"127.0.0.1\"" end test "host without port" do capture_log(fn -> - assert Connection.parse_proxy("localhost") == {:error, :error_parsing_proxy} + assert Connection.parse_proxy("localhost") == {:error, :invalid_proxy} end) =~ "parsing proxy fail \"localhost\"" end test "host with bad port" do capture_log(fn -> - assert Connection.parse_proxy("localhost:port") == {:error, :error_parsing_port_in_proxy} + assert Connection.parse_proxy("localhost:port") == {:error, :invalid_proxy_port} end) =~ "parsing port in proxy fail \"localhost:port\"" end test "ip with bad port" do capture_log(fn -> - assert Connection.parse_proxy("127.0.0.1:15.9") == {:error, :error_parsing_port_in_proxy} + assert Connection.parse_proxy("127.0.0.1:15.9") == {:error, :invalid_proxy_port} end) =~ "parsing port in proxy fail \"127.0.0.1:15.9\"" end test "as tuple without port" do capture_log(fn -> - assert Connection.parse_proxy({:socks5, :localhost}) == {:error, :error_parsing_proxy} + assert Connection.parse_proxy({:socks5, :localhost}) == {:error, :invalid_proxy} end) =~ "parsing proxy fail {:socks5, :localhost}" end From 1ad34bfdbaee7d98167dc7dc7be8b65fd5e6c5f1 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 17:44:04 +0300 Subject: [PATCH 056/188] no try block in checkout connection --- lib/pleroma/http/adapter/gun.ex | 53 ++++++--------------------------- 1 file changed, 9 insertions(+), 44 deletions(-) diff --git a/lib/pleroma/http/adapter/gun.ex b/lib/pleroma/http/adapter/gun.ex index 5e88786bd..30c5c3c16 100644 --- a/lib/pleroma/http/adapter/gun.ex +++ b/lib/pleroma/http/adapter/gun.ex @@ -86,56 +86,21 @@ defp maybe_get_conn(adapter_opts, uri, connection_opts) do end defp try_to_get_conn(uri, opts) do - try do - case Connections.checkin(uri, :gun_connections) do - nil -> - Logger.debug( - "Gun connections pool checkin was not successful. Trying to open conn for next request." - ) - - Task.start(fn -> Pleroma.Gun.Conn.open(uri, :gun_connections, opts) end) - opts - - conn when is_pid(conn) -> - Logger.debug("received conn #{inspect(conn)} #{Connections.compose_uri_log(uri)}") - - opts - |> Keyword.put(:conn, conn) - |> Keyword.put(:close_conn, false) - end - rescue - error -> - Logger.warn( - "Gun connections pool checkin caused error #{Connections.compose_uri_log(uri)} #{ - inspect(error) - }" - ) - - opts - catch - # TODO: here must be no timeouts - :exit, {:timeout, {_, operation, [_, {method, _}, _]}} -> - {:message_queue_len, messages_len} = - :gun_connections - |> Process.whereis() - |> Process.info(:message_queue_len) - - Logger.warn( - "Gun connections pool checkin with timeout error for #{operation} #{method} #{ - Connections.compose_uri_log(uri) - }. Messages length: #{messages_len}" + case Connections.checkin(uri, :gun_connections) do + nil -> + Logger.debug( + "Gun connections pool checkin was not successful. Trying to open conn for next request." ) + Task.start(fn -> Pleroma.Gun.Conn.open(uri, :gun_connections, opts) end) opts - :exit, error -> - Logger.warn( - "Gun pool checkin exited with error #{Connections.compose_uri_log(uri)} #{ - inspect(error) - }" - ) + conn when is_pid(conn) -> + Logger.debug("received conn #{inspect(conn)} #{Connections.compose_uri_log(uri)}") opts + |> Keyword.put(:conn, conn) + |> Keyword.put(:close_conn, false) end end From 8854770fc4e9079131a0897d5fb6c0ccccf98bc6 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 18:01:35 +0300 Subject: [PATCH 057/188] retry and retry_timeout settings default change --- config/config.exs | 4 ++-- docs/configuration/cheatsheet.md | 4 ++-- lib/pleroma/gun/conn.ex | 4 ++-- lib/pleroma/http/adapter/gun.ex | 3 ++- lib/pleroma/pool/connections.ex | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/config/config.exs b/config/config.exs index 661dfad20..f0dab24b5 100644 --- a/config/config.exs +++ b/config/config.exs @@ -609,8 +609,8 @@ config :pleroma, :connections_pool, checkin_timeout: 250, max_connections: 250, - retry: 0, - retry_timeout: 100, + retry: 1, + retry_timeout: 1000, await_up_timeout: 5_000 config :pleroma, :pools, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index ef3cc40e6..a39a7436d 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -406,8 +406,8 @@ It will increase memory usage, but federation would work faster. * `:checkin_timeout` - timeout to checkin connection from pool. Default: 250ms. * `:max_connections` - maximum number of connections in the pool. Default: 250 connections. -* `:retry` - number of retries, while `gun` will try to reconnect if connections goes down. Default: 5. -* `:retry_timeout` - timeout while `gun` will try to reconnect. Default: 100ms. +* `:retry` - number of retries, while `gun` will try to reconnect if connections goes down. Default: 1. +* `:retry_timeout` - timeout while `gun` will try to reconnect. Default: 1000ms. * `:await_up_timeout` - timeout while `gun` will wait until connection is up. Default: 5000ms. ### :pools diff --git a/lib/pleroma/gun/conn.ex b/lib/pleroma/gun/conn.ex index 9ae419092..d73bec360 100644 --- a/lib/pleroma/gun/conn.ex +++ b/lib/pleroma/gun/conn.ex @@ -42,8 +42,8 @@ def open(%URI{} = uri, name, opts) do opts = opts |> Enum.into(%{}) - |> Map.put_new(:retry, pool_opts[:retry] || 0) - |> Map.put_new(:retry_timeout, pool_opts[:retry_timeout] || 100) + |> Map.put_new(:retry, pool_opts[:retry] || 1) + |> Map.put_new(:retry_timeout, pool_opts[:retry_timeout] || 1000) |> Map.put_new(:await_up_timeout, pool_opts[:await_up_timeout] || 5_000) key = "#{uri.scheme}:#{uri.host}:#{uri.port}" diff --git a/lib/pleroma/http/adapter/gun.ex b/lib/pleroma/http/adapter/gun.ex index 30c5c3c16..ecf9c5b62 100644 --- a/lib/pleroma/http/adapter/gun.ex +++ b/lib/pleroma/http/adapter/gun.ex @@ -15,7 +15,8 @@ defmodule Pleroma.HTTP.Adapter.Gun do connect_timeout: 5_000, domain_lookup_timeout: 5_000, tls_handshake_timeout: 5_000, - retry: 0, + retry: 1, + retry_timeout: 1000, await_up_timeout: 5_000 ] diff --git a/lib/pleroma/pool/connections.ex b/lib/pleroma/pool/connections.ex index bde3ffd13..0f7a1bfd8 100644 --- a/lib/pleroma/pool/connections.ex +++ b/lib/pleroma/pool/connections.ex @@ -219,7 +219,7 @@ def handle_info({:gun_up, conn_pid, _protocol}, state) do @impl true def handle_info({:gun_down, conn_pid, _protocol, _reason, _killed}, state) do - retries = Config.get([:connections_pool, :retry], 0) + retries = Config.get([:connections_pool, :retry], 1) # we can't get info on this pid, because pid is dead state = with {key, conn} <- find_conn(state.conns, conn_pid), From f98ee730f01de528797e38f27964b69a465662c4 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 18:53:44 +0300 Subject: [PATCH 058/188] adapter renaming to adapter_helper --- .../http/{adapter.ex => adapter_helper.ex} | 2 +- .../http/{adapter => adapter_helper}/gun.ex | 8 +++--- .../{adapter => adapter_helper}/hackney.ex | 6 ++-- lib/pleroma/http/connection.ex | 8 +++--- .../{adapter => adapter_helper}/gun_test.exs | 4 +-- .../hackney_test.exs | 4 +-- test/http/adapter_helper_test.exs | 28 +++++++++++++++++++ test/http/adapter_test.exs | 27 ------------------ 8 files changed, 44 insertions(+), 43 deletions(-) rename lib/pleroma/http/{adapter.ex => adapter_helper.ex} (96%) rename lib/pleroma/http/{adapter => adapter_helper}/gun.ex (94%) rename lib/pleroma/http/{adapter => adapter_helper}/hackney.ex (87%) rename test/http/{adapter => adapter_helper}/gun_test.exs (99%) rename test/http/{adapter => adapter_helper}/hackney_test.exs (93%) create mode 100644 test/http/adapter_helper_test.exs delete mode 100644 test/http/adapter_test.exs diff --git a/lib/pleroma/http/adapter.ex b/lib/pleroma/http/adapter_helper.ex similarity index 96% rename from lib/pleroma/http/adapter.ex rename to lib/pleroma/http/adapter_helper.ex index a3b84d8f3..2c13666ec 100644 --- a/lib/pleroma/http/adapter.ex +++ b/lib/pleroma/http/adapter_helper.ex @@ -2,7 +2,7 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.HTTP.Adapter do +defmodule Pleroma.HTTP.AdapterHelper do alias Pleroma.HTTP.Connection @type proxy :: diff --git a/lib/pleroma/http/adapter/gun.ex b/lib/pleroma/http/adapter_helper/gun.ex similarity index 94% rename from lib/pleroma/http/adapter/gun.ex rename to lib/pleroma/http/adapter_helper/gun.ex index ecf9c5b62..b3298ec7f 100644 --- a/lib/pleroma/http/adapter/gun.ex +++ b/lib/pleroma/http/adapter_helper/gun.ex @@ -2,10 +2,10 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.HTTP.Adapter.Gun do - @behaviour Pleroma.HTTP.Adapter +defmodule Pleroma.HTTP.AdapterHelper.Gun do + @behaviour Pleroma.HTTP.AdapterHelper - alias Pleroma.HTTP.Adapter + alias Pleroma.HTTP.AdapterHelper require Logger @@ -28,7 +28,7 @@ def options(connection_opts \\ [], %URI{} = uri) do |> Keyword.merge(Pleroma.Config.get([:http, :adapter], [])) |> add_original(uri) |> add_scheme_opts(uri) - |> Adapter.maybe_add_proxy(Adapter.format_proxy(proxy)) + |> AdapterHelper.maybe_add_proxy(AdapterHelper.format_proxy(proxy)) |> maybe_get_conn(uri, connection_opts) end diff --git a/lib/pleroma/http/adapter/hackney.ex b/lib/pleroma/http/adapter_helper/hackney.ex similarity index 87% rename from lib/pleroma/http/adapter/hackney.ex rename to lib/pleroma/http/adapter_helper/hackney.ex index 00db30083..a0e161eaa 100644 --- a/lib/pleroma/http/adapter/hackney.ex +++ b/lib/pleroma/http/adapter_helper/hackney.ex @@ -1,5 +1,5 @@ -defmodule Pleroma.HTTP.Adapter.Hackney do - @behaviour Pleroma.HTTP.Adapter +defmodule Pleroma.HTTP.AdapterHelper.Hackney do + @behaviour Pleroma.HTTP.AdapterHelper @defaults [ connect_timeout: 10_000, @@ -17,7 +17,7 @@ def options(connection_opts \\ [], %URI{} = uri) do |> Keyword.merge(Pleroma.Config.get([:http, :adapter], [])) |> Keyword.merge(connection_opts) |> add_scheme_opts(uri) - |> Pleroma.HTTP.Adapter.maybe_add_proxy(proxy) + |> Pleroma.HTTP.AdapterHelper.maybe_add_proxy(proxy) end defp add_scheme_opts(opts, %URI{scheme: "http"}), do: opts diff --git a/lib/pleroma/http/connection.ex b/lib/pleroma/http/connection.ex index bdd062929..dc2761182 100644 --- a/lib/pleroma/http/connection.ex +++ b/lib/pleroma/http/connection.ex @@ -18,7 +18,7 @@ defmodule Pleroma.HTTP.Connection do require Logger alias Pleroma.Config - alias Pleroma.HTTP.Adapter + alias Pleroma.HTTP.AdapterHelper @doc """ Merge default connection & adapter options with received ones. @@ -50,9 +50,9 @@ def after_request(opts), do: adapter().after_request(opts) defp adapter do case Application.get_env(:tesla, :adapter) do - Tesla.Adapter.Gun -> Adapter.Gun - Tesla.Adapter.Hackney -> Adapter.Hackney - _ -> Adapter + Tesla.Adapter.Gun -> AdapterHelper.Gun + Tesla.Adapter.Hackney -> AdapterHelper.Hackney + _ -> AdapterHelper end end diff --git a/test/http/adapter/gun_test.exs b/test/http/adapter_helper/gun_test.exs similarity index 99% rename from test/http/adapter/gun_test.exs rename to test/http/adapter_helper/gun_test.exs index a05471ac6..bc7e3f0e0 100644 --- a/test/http/adapter/gun_test.exs +++ b/test/http/adapter_helper/gun_test.exs @@ -2,13 +2,13 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.HTTP.Adapter.GunTest do +defmodule Pleroma.HTTP.AdapterHelper.GunTest do use ExUnit.Case, async: true use Pleroma.Tests.Helpers import ExUnit.CaptureLog alias Pleroma.Config alias Pleroma.Gun.Conn - alias Pleroma.HTTP.Adapter.Gun + alias Pleroma.HTTP.AdapterHelper.Gun alias Pleroma.Pool.Connections setup_all do diff --git a/test/http/adapter/hackney_test.exs b/test/http/adapter_helper/hackney_test.exs similarity index 93% rename from test/http/adapter/hackney_test.exs rename to test/http/adapter_helper/hackney_test.exs index 35cb58125..82f5a7883 100644 --- a/test/http/adapter/hackney_test.exs +++ b/test/http/adapter_helper/hackney_test.exs @@ -2,12 +2,12 @@ # Copyright © 2017-2019 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.HTTP.Adapter.HackneyTest do +defmodule Pleroma.HTTP.AdapterHelper.HackneyTest do use ExUnit.Case use Pleroma.Tests.Helpers alias Pleroma.Config - alias Pleroma.HTTP.Adapter.Hackney + alias Pleroma.HTTP.AdapterHelper.Hackney setup_all do uri = URI.parse("http://domain.com") diff --git a/test/http/adapter_helper_test.exs b/test/http/adapter_helper_test.exs new file mode 100644 index 000000000..24d501ad5 --- /dev/null +++ b/test/http/adapter_helper_test.exs @@ -0,0 +1,28 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.AdapterHelperTest do + use ExUnit.Case, async: true + + alias Pleroma.HTTP.AdapterHelper + + describe "format_proxy/1" do + test "with nil" do + assert AdapterHelper.format_proxy(nil) == nil + end + + test "with string" do + assert AdapterHelper.format_proxy("127.0.0.1:8123") == {{127, 0, 0, 1}, 8123} + end + + test "localhost with port" do + assert AdapterHelper.format_proxy("localhost:8123") == {'localhost', 8123} + end + + test "tuple" do + assert AdapterHelper.format_proxy({:socks4, :localhost, 9050}) == + {:socks4, 'localhost', 9050} + end + end +end diff --git a/test/http/adapter_test.exs b/test/http/adapter_test.exs deleted file mode 100644 index 4c805837c..000000000 --- a/test/http/adapter_test.exs +++ /dev/null @@ -1,27 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.HTTP.AdapterTest do - use ExUnit.Case, async: true - - alias Pleroma.HTTP.Adapter - - describe "format_proxy/1" do - test "with nil" do - assert Adapter.format_proxy(nil) == nil - end - - test "with string" do - assert Adapter.format_proxy("127.0.0.1:8123") == {{127, 0, 0, 1}, 8123} - end - - test "localhost with port" do - assert Adapter.format_proxy("localhost:8123") == {'localhost', 8123} - end - - test "tuple" do - assert Adapter.format_proxy({:socks4, :localhost, 9050}) == {:socks4, 'localhost', 9050} - end - end -end From 23f407bf093723344e63eba6a63f5cd58aa7313e Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 18:57:16 +0300 Subject: [PATCH 059/188] don't test gun itself --- test/gun/gun_test.exs | 39 --------------------------------------- 1 file changed, 39 deletions(-) delete mode 100644 test/gun/gun_test.exs diff --git a/test/gun/gun_test.exs b/test/gun/gun_test.exs deleted file mode 100644 index 9f3e0f938..000000000 --- a/test/gun/gun_test.exs +++ /dev/null @@ -1,39 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.GunTest do - use ExUnit.Case - alias Pleroma.Gun - - @moduletag :integration - - test "opens connection and receive response" do - {:ok, conn} = Gun.open('httpbin.org', 443) - assert is_pid(conn) - {:ok, _protocol} = Gun.await_up(conn) - ref = :gun.get(conn, '/get?a=b&c=d') - assert is_reference(ref) - - assert {:response, :nofin, 200, _} = Gun.await(conn, ref) - assert json = receive_response(conn, ref) - - assert %{"args" => %{"a" => "b", "c" => "d"}} = Jason.decode!(json) - - {:ok, pid} = Task.start(fn -> Process.sleep(50) end) - - :ok = :gun.set_owner(conn, pid) - - assert :gun.info(conn).owner == pid - end - - defp receive_response(conn, ref, acc \\ "") do - case Gun.await(conn, ref) do - {:data, :nofin, body} -> - receive_response(conn, ref, acc <> body) - - {:data, :fin, body} -> - acc <> body - end - end -end From 884d9710b209cc9981c7de61d4e95fd26cd83820 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 19:24:14 +0300 Subject: [PATCH 060/188] refactoring for gun api modules --- config/test.exs | 2 +- lib/pleroma/gun/api.ex | 46 ++++++++++----- lib/pleroma/gun/conn.ex | 22 +++---- lib/pleroma/gun/gun.ex | 49 +++++---------- lib/pleroma/pool/connections.ex | 10 ++-- test/http/adapter_helper/gun_test.exs | 2 +- test/http/connection_test.exs | 2 +- test/http_test.exs | 4 +- test/pool/connections_test.exs | 7 +-- test/reverse_proxy/client/tesla_test.exs | 4 +- test/reverse_proxy/reverse_proxy_test.exs | 4 +- .../api/mock.ex => test/support/gun_mock.ex | 59 ++++++++++--------- 12 files changed, 104 insertions(+), 107 deletions(-) rename lib/pleroma/gun/api/mock.ex => test/support/gun_mock.ex (79%) diff --git a/config/test.exs b/config/test.exs index 7cc669c19..bce9dd4aa 100644 --- a/config/test.exs +++ b/config/test.exs @@ -90,7 +90,7 @@ config :pleroma, :modules, runtime_dir: "test/fixtures/modules" -config :pleroma, Pleroma.Gun.API, Pleroma.Gun.API.Mock +config :pleroma, Pleroma.Gun, Pleroma.GunMock config :pleroma, Pleroma.Emails.NewUsersDigestEmail, enabled: true diff --git a/lib/pleroma/gun/api.ex b/lib/pleroma/gun/api.ex index f79c9f443..76aac5874 100644 --- a/lib/pleroma/gun/api.ex +++ b/lib/pleroma/gun/api.ex @@ -3,27 +3,43 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Gun.API do - @callback open(charlist(), pos_integer(), map()) :: {:ok, pid()} - @callback info(pid()) :: map() - @callback close(pid()) :: :ok - @callback await_up(pid, pos_integer()) :: {:ok, atom()} | {:error, atom()} - @callback connect(pid(), map()) :: reference() - @callback await(pid(), reference()) :: {:response, :fin, 200, []} - @callback set_owner(pid(), pid()) :: :ok + @behaviour Pleroma.Gun - def open(host, port, opts), do: api().open(host, port, opts) + alias Pleroma.Gun - def info(pid), do: api().info(pid) + @gun_keys [ + :connect_timeout, + :http_opts, + :http2_opts, + :protocols, + :retry, + :retry_timeout, + :trace, + :transport, + :tls_opts, + :tcp_opts, + :socks_opts, + :ws_opts + ] - def close(pid), do: api().close(pid) + @impl Gun + def open(host, port, opts \\ %{}), do: :gun.open(host, port, Map.take(opts, @gun_keys)) - def await_up(pid, timeout \\ 5_000), do: api().await_up(pid, timeout) + @impl Gun + defdelegate info(pid), to: :gun - def connect(pid, opts), do: api().connect(pid, opts) + @impl Gun + defdelegate close(pid), to: :gun - def await(pid, ref), do: api().await(pid, ref) + @impl Gun + defdelegate await_up(pid, timeout \\ 5_000), to: :gun - def set_owner(pid, owner), do: api().set_owner(pid, owner) + @impl Gun + defdelegate connect(pid, opts), to: :gun - defp api, do: Pleroma.Config.get([Pleroma.Gun.API], Pleroma.Gun) + @impl Gun + defdelegate await(pid, ref), to: :gun + + @impl Gun + defdelegate set_owner(pid, owner), to: :gun end diff --git a/lib/pleroma/gun/conn.ex b/lib/pleroma/gun/conn.ex index d73bec360..319718690 100644 --- a/lib/pleroma/gun/conn.ex +++ b/lib/pleroma/gun/conn.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Gun.Conn do @moduledoc """ Struct for gun connection data """ - alias Pleroma.Gun.API + alias Pleroma.Gun alias Pleroma.Pool.Connections require Logger @@ -65,7 +65,7 @@ def open(%URI{} = uri, name, opts) do last_reference: :os.system_time(:second) } - :ok = API.set_owner(conn_pid, Process.whereis(name)) + :ok = Gun.set_owner(conn_pid, Process.whereis(name)) Connections.add_conn(name, key, conn) end end @@ -77,10 +77,10 @@ defp do_open(uri, %{proxy: {proxy_host, proxy_port}} = opts) do |> add_http2_opts(uri.scheme, Map.get(opts, :tls_opts, [])) with open_opts <- Map.delete(opts, :tls_opts), - {:ok, conn} <- API.open(proxy_host, proxy_port, open_opts), - {:ok, _} <- API.await_up(conn, opts[:await_up_timeout]), - stream <- API.connect(conn, connect_opts), - {:response, :fin, 200, _} <- API.await(conn, stream) do + {:ok, conn} <- Gun.open(proxy_host, proxy_port, open_opts), + {:ok, _} <- Gun.await_up(conn, opts[:await_up_timeout]), + stream <- Gun.connect(conn, connect_opts), + {:response, :fin, 200, _} <- Gun.await(conn, stream) do conn else error -> @@ -115,8 +115,8 @@ defp do_open(uri, %{proxy: {proxy_type, proxy_host, proxy_port}} = opts) do |> Map.put(:protocols, [:socks]) |> Map.put(:socks_opts, socks_opts) - with {:ok, conn} <- API.open(proxy_host, proxy_port, opts), - {:ok, _} <- API.await_up(conn, opts[:await_up_timeout]) do + with {:ok, conn} <- Gun.open(proxy_host, proxy_port, opts), + {:ok, _} <- Gun.await_up(conn, opts[:await_up_timeout]) do conn else error -> @@ -133,8 +133,8 @@ defp do_open(uri, %{proxy: {proxy_type, proxy_host, proxy_port}} = opts) do defp do_open(%URI{host: host, port: port} = uri, opts) do host = Pleroma.HTTP.Connection.parse_host(host) - with {:ok, conn} <- API.open(host, port, opts), - {:ok, _} <- API.await_up(conn, opts[:await_up_timeout]) do + with {:ok, conn} <- Gun.open(host, port, opts), + {:ok, _} <- Gun.await_up(conn, opts[:await_up_timeout]) do conn else error -> @@ -164,7 +164,7 @@ defp close_least_used_and_do_open(name, uri, opts) do with [{close_key, least_used} | _conns] <- Connections.get_unused_conns(name), - :ok <- Pleroma.Gun.API.close(least_used.conn) do + :ok <- Gun.close(least_used.conn) do Connections.remove_conn(name, close_key) do_open(uri, opts) diff --git a/lib/pleroma/gun/gun.ex b/lib/pleroma/gun/gun.ex index da82983b1..35390bb11 100644 --- a/lib/pleroma/gun/gun.ex +++ b/lib/pleroma/gun/gun.ex @@ -3,46 +3,27 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Gun do - @behaviour Pleroma.Gun.API + @callback open(charlist(), pos_integer(), map()) :: {:ok, pid()} + @callback info(pid()) :: map() + @callback close(pid()) :: :ok + @callback await_up(pid, pos_integer()) :: {:ok, atom()} | {:error, atom()} + @callback connect(pid(), map()) :: reference() + @callback await(pid(), reference()) :: {:response, :fin, 200, []} + @callback set_owner(pid(), pid()) :: :ok - alias Pleroma.Gun.API + def open(host, port, opts), do: api().open(host, port, opts) - @gun_keys [ - :connect_timeout, - :http_opts, - :http2_opts, - :protocols, - :retry, - :retry_timeout, - :trace, - :transport, - :tls_opts, - :tcp_opts, - :socks_opts, - :ws_opts - ] + def info(pid), do: api().info(pid) - @impl API - def open(host, port, opts \\ %{}), do: :gun.open(host, port, Map.take(opts, @gun_keys)) + def close(pid), do: api().close(pid) - @impl API - defdelegate info(pid), to: :gun + def await_up(pid, timeout \\ 5_000), do: api().await_up(pid, timeout) - @impl API - defdelegate close(pid), to: :gun + def connect(pid, opts), do: api().connect(pid, opts) - @impl API - defdelegate await_up(pid, timeout \\ 5_000), to: :gun + def await(pid, ref), do: api().await(pid, ref) - @impl API - defdelegate connect(pid, opts), to: :gun + def set_owner(pid, owner), do: api().set_owner(pid, owner) - @impl API - defdelegate await(pid, ref), to: :gun - - @spec flush(pid() | reference()) :: :ok - defdelegate flush(pid), to: :gun - - @impl API - defdelegate set_owner(pid, owner), to: :gun + defp api, do: Pleroma.Config.get([Pleroma.Gun], Pleroma.Gun.API) end diff --git a/lib/pleroma/pool/connections.ex b/lib/pleroma/pool/connections.ex index 0f7a1bfd8..92179fbfc 100644 --- a/lib/pleroma/pool/connections.ex +++ b/lib/pleroma/pool/connections.ex @@ -19,7 +19,7 @@ defmodule Pleroma.Pool.Connections do defstruct conns: %{}, opts: [] - alias Pleroma.Gun.API + alias Pleroma.Gun @spec start_link({atom(), keyword()}) :: {:ok, pid()} def start_link({name, opts}) do @@ -209,7 +209,7 @@ def handle_info({:gun_up, conn_pid, _protocol}, state) do nil -> Logger.debug(":gun_up message for conn which is not found in state") - :ok = API.close(conn_pid) + :ok = Gun.close(conn_pid) state end @@ -226,7 +226,7 @@ def handle_info({:gun_down, conn_pid, _protocol, _reason, _killed}, state) do {true, key} <- {Process.alive?(conn_pid), key} do if conn.retries == retries do Logger.debug("closing conn if retries is eq #{inspect(conn_pid)}") - :ok = API.close(conn.conn) + :ok = Gun.close(conn.conn) put_in( state.conns, @@ -252,7 +252,7 @@ def handle_info({:gun_down, conn_pid, _protocol, _reason, _killed}, state) do nil -> Logger.debug(":gun_down message for conn which is not found in state") - :ok = API.close(conn_pid) + :ok = Gun.close(conn_pid) state end @@ -287,7 +287,7 @@ def handle_info({:DOWN, _ref, :process, conn_pid, reason}, state) do defp compose_key_gun_info(pid) do try do # sometimes :gun.info can raise MatchError, which lead to pool terminate - %{origin_host: origin_host, origin_scheme: scheme, origin_port: port} = API.info(pid) + %{origin_host: origin_host, origin_scheme: scheme, origin_port: port} = Gun.info(pid) host = case :inet.ntoa(origin_host) do diff --git a/test/http/adapter_helper/gun_test.exs b/test/http/adapter_helper/gun_test.exs index bc7e3f0e0..66ca416d9 100644 --- a/test/http/adapter_helper/gun_test.exs +++ b/test/http/adapter_helper/gun_test.exs @@ -12,7 +12,7 @@ defmodule Pleroma.HTTP.AdapterHelper.GunTest do alias Pleroma.Pool.Connections setup_all do - {:ok, _} = Registry.start_link(keys: :unique, name: Pleroma.Gun.API.Mock) + {:ok, _} = Registry.start_link(keys: :unique, name: Pleroma.GunMock) :ok end diff --git a/test/http/connection_test.exs b/test/http/connection_test.exs index 37de11e7a..3f32898cb 100644 --- a/test/http/connection_test.exs +++ b/test/http/connection_test.exs @@ -10,7 +10,7 @@ defmodule Pleroma.HTTP.ConnectionTest do alias Pleroma.HTTP.Connection setup_all do - {:ok, _} = Registry.start_link(keys: :unique, name: Pleroma.Gun.API.Mock) + {:ok, _} = Registry.start_link(keys: :unique, name: Pleroma.GunMock) :ok end diff --git a/test/http_test.exs b/test/http_test.exs index 83c27f6e1..d45d34f32 100644 --- a/test/http_test.exs +++ b/test/http_test.exs @@ -61,8 +61,8 @@ test "returns successfully result" do describe "connection pools" do @describetag :integration - clear_config(Pleroma.Gun.API) do - Pleroma.Config.put(Pleroma.Gun.API, Pleroma.Gun) + clear_config(Pleroma.Gun) do + Pleroma.Config.put(Pleroma.Gun, Pleroma.Gun.API) end test "gun" do diff --git a/test/pool/connections_test.exs b/test/pool/connections_test.exs index a084f31b9..31dd5f6fa 100644 --- a/test/pool/connections_test.exs +++ b/test/pool/connections_test.exs @@ -6,12 +6,11 @@ defmodule Pleroma.Pool.ConnectionsTest do use ExUnit.Case use Pleroma.Tests.Helpers import ExUnit.CaptureLog - alias Pleroma.Gun.API alias Pleroma.Gun.Conn alias Pleroma.Pool.Connections setup_all do - {:ok, _} = Registry.start_link(keys: :unique, name: API.Mock) + {:ok, _} = Registry.start_link(keys: :unique, name: Pleroma.GunMock) :ok end @@ -439,8 +438,8 @@ test "remove frequently used and idle", %{name: name} do describe "integration test" do @describetag :integration - clear_config(API) do - Pleroma.Config.put(API, Pleroma.Gun) + clear_config(Pleroma.Gun) do + Pleroma.Config.put(Pleroma.Gun, Pleroma.Gun.API) end test "opens connection and change owner", %{name: name} do diff --git a/test/reverse_proxy/client/tesla_test.exs b/test/reverse_proxy/client/tesla_test.exs index 231271b0d..78bd31530 100644 --- a/test/reverse_proxy/client/tesla_test.exs +++ b/test/reverse_proxy/client/tesla_test.exs @@ -8,8 +8,8 @@ defmodule Pleroma.ReverseProxy.Client.TeslaTest do alias Pleroma.ReverseProxy.Client @moduletag :integration - clear_config_all(Pleroma.Gun.API) do - Pleroma.Config.put(Pleroma.Gun.API, Pleroma.Gun) + clear_config_all(Pleroma.Gun) do + Pleroma.Config.put(Pleroma.Gun, Pleroma.Gun.API) end setup do diff --git a/test/reverse_proxy/reverse_proxy_test.exs b/test/reverse_proxy/reverse_proxy_test.exs index f61fc02c5..8e72698ee 100644 --- a/test/reverse_proxy/reverse_proxy_test.exs +++ b/test/reverse_proxy/reverse_proxy_test.exs @@ -349,8 +349,8 @@ test "with content-disposition header", %{conn: conn} do Pleroma.Config.put(Pleroma.ReverseProxy.Client, Pleroma.ReverseProxy.Client.Tesla) end - clear_config(Pleroma.Gun.API) do - Pleroma.Config.put(Pleroma.Gun.API, Pleroma.Gun) + clear_config(Pleroma.Gun) do + Pleroma.Config.put(Pleroma.Gun, Pleroma.Gun.API) end setup do diff --git a/lib/pleroma/gun/api/mock.ex b/test/support/gun_mock.ex similarity index 79% rename from lib/pleroma/gun/api/mock.ex rename to test/support/gun_mock.ex index 6d24b0e69..e13afd08c 100644 --- a/lib/pleroma/gun/api/mock.ex +++ b/test/support/gun_mock.ex @@ -2,16 +2,17 @@ # Copyright © 2017-2019 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Gun.API.Mock do - @behaviour Pleroma.Gun.API +defmodule Pleroma.GunMock do + @behaviour Pleroma.Gun - alias Pleroma.Gun.API + alias Pleroma.Gun + alias Pleroma.GunMock - @impl API + @impl Gun def open('some-domain.com', 443, _) do {:ok, conn_pid} = Task.start_link(fn -> Process.sleep(1_000) end) - Registry.register(API.Mock, conn_pid, %{ + Registry.register(GunMock, conn_pid, %{ origin_scheme: "https", origin_host: 'some-domain.com', origin_port: 443 @@ -20,7 +21,7 @@ def open('some-domain.com', 443, _) do {:ok, conn_pid} end - @impl API + @impl Gun def open(ip, port, _) when ip in [{10_755, 10_368, 61_708, 131, 64_206, 45_068, 0, 9_694}, {127, 0, 0, 1}] and port in [80, 443] do @@ -28,7 +29,7 @@ def open(ip, port, _) scheme = if port == 443, do: "https", else: "http" - Registry.register(API.Mock, conn_pid, %{ + Registry.register(GunMock, conn_pid, %{ origin_scheme: scheme, origin_host: ip, origin_port: port @@ -37,7 +38,7 @@ def open(ip, port, _) {:ok, conn_pid} end - @impl API + @impl Gun def open('localhost', 1234, %{ protocols: [:socks], proxy: {:socks5, 'localhost', 1234}, @@ -45,7 +46,7 @@ def open('localhost', 1234, %{ }) do {:ok, conn_pid} = Task.start_link(fn -> Process.sleep(1_000) end) - Registry.register(API.Mock, conn_pid, %{ + Registry.register(GunMock, conn_pid, %{ origin_scheme: "http", origin_host: 'proxy-socks.com', origin_port: 80 @@ -54,7 +55,7 @@ def open('localhost', 1234, %{ {:ok, conn_pid} end - @impl API + @impl Gun def open('localhost', 1234, %{ protocols: [:socks], proxy: {:socks4, 'localhost', 1234}, @@ -69,7 +70,7 @@ def open('localhost', 1234, %{ }) do {:ok, conn_pid} = Task.start_link(fn -> Process.sleep(1_000) end) - Registry.register(API.Mock, conn_pid, %{ + Registry.register(GunMock, conn_pid, %{ origin_scheme: "https", origin_host: 'proxy-socks.com', origin_port: 443 @@ -78,14 +79,14 @@ def open('localhost', 1234, %{ {:ok, conn_pid} end - @impl API + @impl Gun def open('gun-not-up.com', 80, _opts), do: {:error, :timeout} - @impl API + @impl Gun def open('example.com', port, _) when port in [443, 115] do {:ok, conn_pid} = Task.start_link(fn -> Process.sleep(1_000) end) - Registry.register(API.Mock, conn_pid, %{ + Registry.register(GunMock, conn_pid, %{ origin_scheme: "https", origin_host: 'example.com', origin_port: 443 @@ -94,11 +95,11 @@ def open('example.com', port, _) when port in [443, 115] do {:ok, conn_pid} end - @impl API + @impl Gun def open(domain, 80, _) do {:ok, conn_pid} = Task.start_link(fn -> Process.sleep(1_000) end) - Registry.register(API.Mock, conn_pid, %{ + Registry.register(GunMock, conn_pid, %{ origin_scheme: "http", origin_host: domain, origin_port: 80 @@ -107,48 +108,48 @@ def open(domain, 80, _) do {:ok, conn_pid} end - @impl API + @impl Gun def open({127, 0, 0, 1}, 8123, _) do Task.start_link(fn -> Process.sleep(1_000) end) end - @impl API + @impl Gun def open('localhost', 9050, _) do Task.start_link(fn -> Process.sleep(1_000) end) end - @impl API + @impl Gun def await_up(_pid, _timeout), do: {:ok, :http} - @impl API + @impl Gun def set_owner(_pid, _owner), do: :ok - @impl API + @impl Gun def connect(pid, %{host: _, port: 80}) do ref = make_ref() - Registry.register(API.Mock, ref, pid) + Registry.register(GunMock, ref, pid) ref end - @impl API + @impl Gun def connect(pid, %{host: _, port: 443, protocols: [:http2], transport: :tls}) do ref = make_ref() - Registry.register(API.Mock, ref, pid) + Registry.register(GunMock, ref, pid) ref end - @impl API + @impl Gun def await(pid, ref) do - [{_, ^pid}] = Registry.lookup(API.Mock, ref) + [{_, ^pid}] = Registry.lookup(GunMock, ref) {:response, :fin, 200, []} end - @impl API + @impl Gun def info(pid) do - [{_, info}] = Registry.lookup(API.Mock, pid) + [{_, info}] = Registry.lookup(GunMock, pid) info end - @impl API + @impl Gun def close(_pid), do: :ok end From d9c5ae7c09c7cbf3f4f66e01b7ed69a3d6388916 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 3 Mar 2020 17:16:24 -0600 Subject: [PATCH 061/188] Update Copyrights for gun related files --- lib/pleroma/gun/api.ex | 2 +- lib/pleroma/gun/gun.ex | 2 +- lib/pleroma/http/request.ex | 2 +- lib/pleroma/pool/connections.ex | 2 +- lib/pleroma/pool/pool.ex | 2 +- lib/pleroma/pool/request.ex | 2 +- lib/pleroma/pool/supervisor.ex | 2 +- lib/pleroma/reverse_proxy/client/hackney.ex | 2 +- lib/pleroma/reverse_proxy/client/tesla.ex | 2 +- test/http/adapter_helper/hackney_test.exs | 2 +- test/http/connection_test.exs | 2 +- test/pool/connections_test.exs | 2 +- test/reverse_proxy/client/tesla_test.exs | 2 +- test/support/gun_mock.ex | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/pleroma/gun/api.ex b/lib/pleroma/gun/api.ex index 76aac5874..f51cd7db8 100644 --- a/lib/pleroma/gun/api.ex +++ b/lib/pleroma/gun/api.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Gun.API do diff --git a/lib/pleroma/gun/gun.ex b/lib/pleroma/gun/gun.ex index 35390bb11..81855e89e 100644 --- a/lib/pleroma/gun/gun.ex +++ b/lib/pleroma/gun/gun.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Gun do diff --git a/lib/pleroma/http/request.ex b/lib/pleroma/http/request.ex index 891d88d53..761bd6ccf 100644 --- a/lib/pleroma/http/request.ex +++ b/lib/pleroma/http/request.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.HTTP.Request do diff --git a/lib/pleroma/pool/connections.ex b/lib/pleroma/pool/connections.ex index 92179fbfc..f1fab2a24 100644 --- a/lib/pleroma/pool/connections.ex +++ b/lib/pleroma/pool/connections.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Pool.Connections do diff --git a/lib/pleroma/pool/pool.ex b/lib/pleroma/pool/pool.ex index a7ae64ce4..21a6fbbc5 100644 --- a/lib/pleroma/pool/pool.ex +++ b/lib/pleroma/pool/pool.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Pool do diff --git a/lib/pleroma/pool/request.ex b/lib/pleroma/pool/request.ex index 2c3574561..cce309599 100644 --- a/lib/pleroma/pool/request.ex +++ b/lib/pleroma/pool/request.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Pool.Request do diff --git a/lib/pleroma/pool/supervisor.ex b/lib/pleroma/pool/supervisor.ex index 32be2264d..f436849ac 100644 --- a/lib/pleroma/pool/supervisor.ex +++ b/lib/pleroma/pool/supervisor.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Pool.Supervisor do diff --git a/lib/pleroma/reverse_proxy/client/hackney.ex b/lib/pleroma/reverse_proxy/client/hackney.ex index e41560ab0..e84118a90 100644 --- a/lib/pleroma/reverse_proxy/client/hackney.ex +++ b/lib/pleroma/reverse_proxy/client/hackney.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.ReverseProxy.Client.Hackney do diff --git a/lib/pleroma/reverse_proxy/client/tesla.ex b/lib/pleroma/reverse_proxy/client/tesla.ex index 80a0c8972..dbc6b66a3 100644 --- a/lib/pleroma/reverse_proxy/client/tesla.ex +++ b/lib/pleroma/reverse_proxy/client/tesla.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.ReverseProxy.Client.Tesla do diff --git a/test/http/adapter_helper/hackney_test.exs b/test/http/adapter_helper/hackney_test.exs index 82f5a7883..3306616ef 100644 --- a/test/http/adapter_helper/hackney_test.exs +++ b/test/http/adapter_helper/hackney_test.exs @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.HTTP.AdapterHelper.HackneyTest do diff --git a/test/http/connection_test.exs b/test/http/connection_test.exs index 3f32898cb..5c1ecda0b 100644 --- a/test/http/connection_test.exs +++ b/test/http/connection_test.exs @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.HTTP.ConnectionTest do diff --git a/test/pool/connections_test.exs b/test/pool/connections_test.exs index 31dd5f6fa..963fae665 100644 --- a/test/pool/connections_test.exs +++ b/test/pool/connections_test.exs @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Pool.ConnectionsTest do diff --git a/test/reverse_proxy/client/tesla_test.exs b/test/reverse_proxy/client/tesla_test.exs index 78bd31530..c8b0d5842 100644 --- a/test/reverse_proxy/client/tesla_test.exs +++ b/test/reverse_proxy/client/tesla_test.exs @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.ReverseProxy.Client.TeslaTest do diff --git a/test/support/gun_mock.ex b/test/support/gun_mock.ex index e13afd08c..9d664e366 100644 --- a/test/support/gun_mock.ex +++ b/test/support/gun_mock.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.GunMock do From 8d9dee1ba951e81aaa08b4db64b431a7456dae56 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 4 Mar 2020 08:56:36 +0300 Subject: [PATCH 062/188] retry_timeout description change --- docs/configuration/cheatsheet.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index a39a7436d..85cc6170a 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -407,7 +407,7 @@ It will increase memory usage, but federation would work faster. * `:checkin_timeout` - timeout to checkin connection from pool. Default: 250ms. * `:max_connections` - maximum number of connections in the pool. Default: 250 connections. * `:retry` - number of retries, while `gun` will try to reconnect if connections goes down. Default: 1. -* `:retry_timeout` - timeout while `gun` will try to reconnect. Default: 1000ms. +* `:retry_timeout` - time between retries when gun will try to reconnect in milliseconds. Default: 1000ms. * `:await_up_timeout` - timeout while `gun` will wait until connection is up. Default: 5000ms. ### :pools From 6b2fb9160cd945cdd4b1265c793d1f85d559fccb Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 4 Mar 2020 09:23:42 +0300 Subject: [PATCH 063/188] otp version --- lib/pleroma/application.ex | 20 ++++++++++++- lib/pleroma/otp_version.ex | 61 +++++--------------------------------- test/otp_version_test.exs | 18 ++++++----- 3 files changed, 38 insertions(+), 61 deletions(-) diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index d0b9c3c41..c8a0617a5 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -43,7 +43,25 @@ def start(_type, _args) do load_custom_modules() if adapter() == Tesla.Adapter.Gun do - Pleroma.OTPVersion.check!() + if version = Pleroma.OTPVersion.version() do + [major, minor] = + version + |> String.split(".") + |> Enum.map(&String.to_integer/1) + |> Enum.take(2) + + if (major == 22 and minor < 2) or major < 22 do + raise " + !!!OTP VERSION WARNING!!! + You are using gun adapter with OTP version #{version}, which doesn't support correct handling of unordered certificates chains. + " + end + else + raise " + !!!OTP VERSION WARNING!!! + To support correct handling of unordered certificates chains - OTP version must be > 22.2. + " + end end # Define workers and child supervisors to be supervised diff --git a/lib/pleroma/otp_version.ex b/lib/pleroma/otp_version.ex index 9ced2d27d..114d0054f 100644 --- a/lib/pleroma/otp_version.ex +++ b/lib/pleroma/otp_version.ex @@ -3,71 +3,26 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.OTPVersion do - @type check_status() :: :ok | :undefined | {:error, String.t()} - - @spec check!() :: :ok | no_return() - def check! do - case check() do - :ok -> - :ok - - {:error, version} -> - raise " - !!!OTP VERSION WARNING!!! - You are using gun adapter with OTP version #{version}, which doesn't support correct handling of unordered certificates chains. - " - - :undefined -> - raise " - !!!OTP VERSION WARNING!!! - To support correct handling of unordered certificates chains - OTP version must be > 22.2. - " - end - end - - @spec check() :: check_status() - def check do + @spec version() :: String.t() | nil + def version do # OTP Version https://erlang.org/doc/system_principles/versions.html#otp-version [ Path.join(:code.root_dir(), "OTP_VERSION"), Path.join([:code.root_dir(), "releases", :erlang.system_info(:otp_release), "OTP_VERSION"]) ] |> get_version_from_files() - |> do_check() end - @spec check([Path.t()]) :: check_status() - def check(paths) do - paths - |> get_version_from_files() - |> do_check() - end + @spec get_version_from_files([Path.t()]) :: String.t() | nil + def get_version_from_files([]), do: nil - defp get_version_from_files([]), do: nil - - defp get_version_from_files([path | paths]) do + def get_version_from_files([path | paths]) do if File.exists?(path) do - File.read!(path) + path + |> File.read!() + |> String.replace(~r/\r|\n|\s/, "") else get_version_from_files(paths) end end - - defp do_check(nil), do: :undefined - - defp do_check(version) do - version = String.replace(version, ~r/\r|\n|\s/, "") - - [major, minor] = - version - |> String.split(".") - |> Enum.map(&String.to_integer/1) - |> Enum.take(2) - - if (major == 22 and minor >= 2) or major > 22 do - :ok - else - {:error, version} - end - end end diff --git a/test/otp_version_test.exs b/test/otp_version_test.exs index af278cc72..7d2538ec8 100644 --- a/test/otp_version_test.exs +++ b/test/otp_version_test.exs @@ -9,30 +9,34 @@ defmodule Pleroma.OTPVersionTest do describe "check/1" do test "22.4" do - assert OTPVersion.check(["test/fixtures/warnings/otp_version/22.4"]) == :ok + assert OTPVersion.get_version_from_files(["test/fixtures/warnings/otp_version/22.4"]) == + "22.4" end test "22.1" do - assert OTPVersion.check(["test/fixtures/warnings/otp_version/22.1"]) == {:error, "22.1"} + assert OTPVersion.get_version_from_files(["test/fixtures/warnings/otp_version/22.1"]) == + "22.1" end test "21.1" do - assert OTPVersion.check(["test/fixtures/warnings/otp_version/21.1"]) == {:error, "21.1"} + assert OTPVersion.get_version_from_files(["test/fixtures/warnings/otp_version/21.1"]) == + "21.1" end test "23.0" do - assert OTPVersion.check(["test/fixtures/warnings/otp_version/23.0"]) == :ok + assert OTPVersion.get_version_from_files(["test/fixtures/warnings/otp_version/23.0"]) == + "23.0" end test "with non existance file" do - assert OTPVersion.check([ + assert OTPVersion.get_version_from_files([ "test/fixtures/warnings/otp_version/non-exising", "test/fixtures/warnings/otp_version/22.4" - ]) == :ok + ]) == "22.4" end test "empty paths" do - assert OTPVersion.check([]) == :undefined + assert OTPVersion.get_version_from_files([]) == nil end end end From 22d52f5691d985e7daaa955e97e0722f038f6fae Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 4 Mar 2020 09:41:23 +0300 Subject: [PATCH 064/188] same copyright date format --- lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex | 2 +- lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex | 2 +- priv/repo/migrations/20190408123347_create_conversations.exs | 2 +- test/web/activity_pub/mrf/anti_followbot_policy_test.exs | 2 +- test/web/activity_pub/mrf/anti_link_spam_policy_test.exs | 2 +- test/web/activity_pub/mrf/ensure_re_prepended_test.exs | 2 +- test/web/activity_pub/mrf/no_placeholder_text_policy_test.exs | 2 +- test/web/activity_pub/mrf/normalize_markup_test.exs | 2 +- test/web/activity_pub/mrf/object_age_policy_test.exs | 2 +- test/web/activity_pub/mrf/reject_non_public_test.exs | 2 +- test/web/activity_pub/mrf/simple_policy_test.exs | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex index b3547ecd4..0270b96ae 100644 --- a/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.AntiFollowbotPolicy do diff --git a/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex index f67f48ab6..fc3475048 100644 --- a/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicy do diff --git a/priv/repo/migrations/20190408123347_create_conversations.exs b/priv/repo/migrations/20190408123347_create_conversations.exs index d75459e82..3eaa6136c 100644 --- a/priv/repo/migrations/20190408123347_create_conversations.exs +++ b/priv/repo/migrations/20190408123347_create_conversations.exs @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Repo.Migrations.CreateConversations do diff --git a/test/web/activity_pub/mrf/anti_followbot_policy_test.exs b/test/web/activity_pub/mrf/anti_followbot_policy_test.exs index 37a7bfcf7..fca0de7c6 100644 --- a/test/web/activity_pub/mrf/anti_followbot_policy_test.exs +++ b/test/web/activity_pub/mrf/anti_followbot_policy_test.exs @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.AntiFollowbotPolicyTest do diff --git a/test/web/activity_pub/mrf/anti_link_spam_policy_test.exs b/test/web/activity_pub/mrf/anti_link_spam_policy_test.exs index b524fdd23..fc0be6f91 100644 --- a/test/web/activity_pub/mrf/anti_link_spam_policy_test.exs +++ b/test/web/activity_pub/mrf/anti_link_spam_policy_test.exs @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicyTest do diff --git a/test/web/activity_pub/mrf/ensure_re_prepended_test.exs b/test/web/activity_pub/mrf/ensure_re_prepended_test.exs index dbc8b9e80..38ddec5bb 100644 --- a/test/web/activity_pub/mrf/ensure_re_prepended_test.exs +++ b/test/web/activity_pub/mrf/ensure_re_prepended_test.exs @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.EnsureRePrependedTest do diff --git a/test/web/activity_pub/mrf/no_placeholder_text_policy_test.exs b/test/web/activity_pub/mrf/no_placeholder_text_policy_test.exs index 63ed71129..64ea61dd4 100644 --- a/test/web/activity_pub/mrf/no_placeholder_text_policy_test.exs +++ b/test/web/activity_pub/mrf/no_placeholder_text_policy_test.exs @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicyTest do diff --git a/test/web/activity_pub/mrf/normalize_markup_test.exs b/test/web/activity_pub/mrf/normalize_markup_test.exs index 0207be56b..9b39c45bd 100644 --- a/test/web/activity_pub/mrf/normalize_markup_test.exs +++ b/test/web/activity_pub/mrf/normalize_markup_test.exs @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkupTest do diff --git a/test/web/activity_pub/mrf/object_age_policy_test.exs b/test/web/activity_pub/mrf/object_age_policy_test.exs index 643609da4..e521fae44 100644 --- a/test/web/activity_pub/mrf/object_age_policy_test.exs +++ b/test/web/activity_pub/mrf/object_age_policy_test.exs @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicyTest do diff --git a/test/web/activity_pub/mrf/reject_non_public_test.exs b/test/web/activity_pub/mrf/reject_non_public_test.exs index fc1d190bb..5cc68bca8 100644 --- a/test/web/activity_pub/mrf/reject_non_public_test.exs +++ b/test/web/activity_pub/mrf/reject_non_public_test.exs @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.RejectNonPublicTest do diff --git a/test/web/activity_pub/mrf/simple_policy_test.exs b/test/web/activity_pub/mrf/simple_policy_test.exs index df0f223f8..e825a1514 100644 --- a/test/web/activity_pub/mrf/simple_policy_test.exs +++ b/test/web/activity_pub/mrf/simple_policy_test.exs @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do From d6bebd4f9c8086dd87c75f3637a5d392a05f2daf Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 4 Mar 2020 18:13:24 +0300 Subject: [PATCH 065/188] moving some logic to tesla adapter - checking original inside gun adapter - flushing streams on max_body error --- lib/pleroma/http/adapter_helper/gun.ex | 17 ++--------------- lib/pleroma/pool/request.ex | 10 ++-------- mix.exs | 2 +- mix.lock | 3 +-- test/http/adapter_helper/gun_test.exs | 7 ------- test/http/connection_test.exs | 1 - 6 files changed, 6 insertions(+), 34 deletions(-) diff --git a/lib/pleroma/http/adapter_helper/gun.ex b/lib/pleroma/http/adapter_helper/gun.ex index b3298ec7f..5d5870d90 100644 --- a/lib/pleroma/http/adapter_helper/gun.ex +++ b/lib/pleroma/http/adapter_helper/gun.ex @@ -26,7 +26,6 @@ def options(connection_opts \\ [], %URI{} = uri) do @defaults |> Keyword.merge(Pleroma.Config.get([:http, :adapter], [])) - |> add_original(uri) |> add_scheme_opts(uri) |> AdapterHelper.maybe_add_proxy(AdapterHelper.format_proxy(proxy)) |> maybe_get_conn(uri, connection_opts) @@ -42,17 +41,12 @@ def after_request(opts) do :ok end - defp add_original(opts, %URI{host: host, port: port}) do - formatted_host = format_host(host) - - Keyword.put(opts, :original, "#{formatted_host}:#{port}") - end - defp add_scheme_opts(opts, %URI{scheme: "http"}), do: opts - defp add_scheme_opts(opts, %URI{scheme: "https", host: host, port: port}) do + defp add_scheme_opts(opts, %URI{scheme: "https", host: host}) do adapter_opts = [ certificates_verification: true, + transport: :tls, tls_opts: [ verify: :verify_peer, cacertfile: CAStore.file_path(), @@ -63,13 +57,6 @@ defp add_scheme_opts(opts, %URI{scheme: "https", host: host, port: port}) do ] ] - adapter_opts = - if port != 443 do - Keyword.put(adapter_opts, :transport, :tls) - else - adapter_opts - end - Keyword.merge(opts, adapter_opts) end diff --git a/lib/pleroma/pool/request.ex b/lib/pleroma/pool/request.ex index cce309599..0f271b3d0 100644 --- a/lib/pleroma/pool/request.ex +++ b/lib/pleroma/pool/request.ex @@ -28,12 +28,7 @@ def handle_call({:execute, client, request}, _from, state) do end @impl true - def handle_info({:gun_data, _conn, stream, _, _}, state) do - # in some cases if we reuse conn and got {:error, :body_too_large} - # gun continues to send messages to this process, - # so we flush messages for this request - :ok = :gun.flush(stream) - + def handle_info({:gun_data, _conn, _stream, _, _}, state) do {:noreply, state} end @@ -49,8 +44,7 @@ def handle_info({:gun_down, _conn, _protocol, _reason, _killed}, state) do end @impl true - def handle_info({:gun_error, _conn, stream, _error}, state) do - :ok = :gun.flush(stream) + def handle_info({:gun_error, _conn, _stream, _error}, state) do {:noreply, state} end diff --git a/mix.exs b/mix.exs index 5c1d89208..43e7e6f63 100644 --- a/mix.exs +++ b/mix.exs @@ -122,7 +122,7 @@ defp deps do # {:tesla, "~> 1.3", override: true}, {:tesla, git: "https://git.pleroma.social/pleroma/elixir-libraries/tesla.git", - ref: "922cc3db13b421763edbea76246b8ea61c38c6fa", + ref: "67436cf003d40370e944462649193706bb22ca35", override: true}, {:castore, "~> 0.1"}, {:cowlib, "~> 2.8", override: true}, diff --git a/mix.lock b/mix.lock index 255b4888b..b5daf50dc 100644 --- a/mix.lock +++ b/mix.lock @@ -102,7 +102,7 @@ "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"}, "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"}, - "tesla": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/tesla.git", "922cc3db13b421763edbea76246b8ea61c38c6fa", [ref: "922cc3db13b421763edbea76246b8ea61c38c6fa"]}, + "tesla": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/tesla.git", "67436cf003d40370e944462649193706bb22ca35", [ref: "67436cf003d40370e944462649193706bb22ca35"]}, "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"}, "trailing_format_plug": {:hex, :trailing_format_plug, "0.0.7", "64b877f912cf7273bed03379936df39894149e35137ac9509117e59866e10e45", [:mix], [{:plug, "> 0.12.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bd4fde4c15f3e993a999e019d64347489b91b7a9096af68b2bdadd192afa693f"}, "tzdata": {:hex, :tzdata, "0.5.22", "f2ba9105117ee0360eae2eca389783ef7db36d533899b2e84559404dbc77ebb8", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "cd66c8a1e6a9e121d1f538b01bef459334bb4029a1ffb4eeeb5e4eae0337e7b6"}, @@ -112,4 +112,3 @@ "web_push_encryption": {:hex, :web_push_encryption, "0.2.3", "a0ceab85a805a30852f143d22d71c434046fbdbafbc7292e7887cec500826a80", [:mix], [{:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:poison, "~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm", "9315c8f37c108835cf3f8e9157d7a9b8f420a34f402d1b1620a31aed5b93ecdf"}, "websocket_client": {:git, "https://github.com/jeremyong/websocket_client.git", "9a6f65d05ebf2725d62fb19262b21f1805a59fbf", []}, } - diff --git a/test/http/adapter_helper/gun_test.exs b/test/http/adapter_helper/gun_test.exs index 66ca416d9..c1bf909a6 100644 --- a/test/http/adapter_helper/gun_test.exs +++ b/test/http/adapter_helper/gun_test.exs @@ -35,8 +35,6 @@ test "https url with default port" do {&:ssl_verify_hostname.verify_fun/3, [check_hostname: 'example.com']} assert File.exists?(tls_opts[:cacertfile]) - - assert opts[:original] == "example.com:443" end test "https ipv4 with default port" do @@ -46,8 +44,6 @@ test "https ipv4 with default port" do assert opts[:tls_opts][:verify_fun] == {&:ssl_verify_hostname.verify_fun/3, [check_hostname: '127.0.0.1']} - - assert opts[:original] == "127.0.0.1:443" end test "https ipv6 with default port" do @@ -58,8 +54,6 @@ test "https ipv6 with default port" do assert opts[:tls_opts][:verify_fun] == {&:ssl_verify_hostname.verify_fun/3, [check_hostname: '2a03:2880:f10c:83:face:b00c:0:25de']} - - assert opts[:original] == "2a03:2880:f10c:83:face:b00c:0:25de:443" end test "https url with non standart port" do @@ -129,7 +123,6 @@ test "default ssl adapter opts with connection" do assert tls_opts[:depth] == 20 assert tls_opts[:reuse_sessions] == false - assert opts[:original] == "some-domain.com:443" assert opts[:close_conn] == false assert is_pid(opts[:conn]) end diff --git a/test/http/connection_test.exs b/test/http/connection_test.exs index 5c1ecda0b..d4db3798c 100644 --- a/test/http/connection_test.exs +++ b/test/http/connection_test.exs @@ -134,7 +134,6 @@ test "default ssl adapter opts with connection" do assert tls_opts[:depth] == 20 assert tls_opts[:reuse_sessions] == false - assert opts[:original] == "some-domain.com:443" assert opts[:close_conn] == false assert is_pid(opts[:conn]) end From fe47bcde8c20d7c968a7fb20637b4bccc6389691 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 4 Mar 2020 19:44:03 +0300 Subject: [PATCH 066/188] updating tesla ref --- mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index 43e7e6f63..3b1bbbaf2 100644 --- a/mix.exs +++ b/mix.exs @@ -122,7 +122,7 @@ defp deps do # {:tesla, "~> 1.3", override: true}, {:tesla, git: "https://git.pleroma.social/pleroma/elixir-libraries/tesla.git", - ref: "67436cf003d40370e944462649193706bb22ca35", + ref: "61b7503cef33f00834f78ddfafe0d5d9dec2270b", override: true}, {:castore, "~> 0.1"}, {:cowlib, "~> 2.8", override: true}, diff --git a/mix.lock b/mix.lock index b5daf50dc..af53e5c0f 100644 --- a/mix.lock +++ b/mix.lock @@ -102,7 +102,7 @@ "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"}, "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"}, - "tesla": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/tesla.git", "67436cf003d40370e944462649193706bb22ca35", [ref: "67436cf003d40370e944462649193706bb22ca35"]}, + "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"}, "trailing_format_plug": {:hex, :trailing_format_plug, "0.0.7", "64b877f912cf7273bed03379936df39894149e35137ac9509117e59866e10e45", [:mix], [{:plug, "> 0.12.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bd4fde4c15f3e993a999e019d64347489b91b7a9096af68b2bdadd192afa693f"}, "tzdata": {:hex, :tzdata, "0.5.22", "f2ba9105117ee0360eae2eca389783ef7db36d533899b2e84559404dbc77ebb8", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "cd66c8a1e6a9e121d1f538b01bef459334bb4029a1ffb4eeeb5e4eae0337e7b6"}, From b34bc669b91903a4567f6f527ebe16f9cd7e0ccf Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 4 Mar 2020 20:09:18 +0300 Subject: [PATCH 067/188] adding descriptions --- config/description.exs | 213 +++++++++++++++++++++++++++++++ docs/configuration/cheatsheet.md | 4 +- 2 files changed, 215 insertions(+), 2 deletions(-) diff --git a/config/description.exs b/config/description.exs index 307f8b5bc..531d73145 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2966,5 +2966,218 @@ suggestions: [2] } ] + }, + %{ + group: :pleroma, + key: :connections_pool, + type: :group, + description: "Advanced settings for `gun` connections pool", + children: [ + %{ + key: :checkin_timeout, + type: :integer, + description: "Timeout to checkin connection from pool. Default: 250ms.", + suggestions: [250] + }, + %{ + key: :max_connections, + type: :integer, + description: "Maximum number of connections in the pool. Default: 250 connections.", + suggestions: [250] + }, + %{ + key: :retry, + type: :integer, + description: + "Number of retries, while `gun` will try to reconnect if connection goes down. Default: 1.", + suggestions: [1] + }, + %{ + key: :retry_timeout, + type: :integer, + description: + "Time between retries when `gun` will try to reconnect in milliseconds. Default: 1000ms.", + suggestions: [1000] + }, + %{ + key: :await_up_timeout, + type: :integer, + description: "Timeout while `gun` will wait until connection is up. Default: 5000ms.", + suggestions: [5000] + } + ] + }, + %{ + group: :pleroma, + key: :pools, + type: :group, + description: "Advanced settings for `gun` workers pools", + children: [ + %{ + key: :federation, + type: :keyword, + description: "Settings for federation pool.", + children: [ + %{ + key: :size, + type: :integer, + description: "Number workers in the pool.", + suggestions: [50] + }, + %{ + key: :max_overflow, + type: :integer, + description: "Number of additional workers if pool is under load.", + suggestions: [10] + }, + %{ + key: :timeout, + type: :integer, + description: "Timeout while `gun` will wait for response.", + suggestions: [150_000] + } + ] + }, + %{ + key: :media, + type: :keyword, + description: "Settings for media pool.", + children: [ + %{ + key: :size, + type: :integer, + description: "Number workers in the pool.", + suggestions: [50] + }, + %{ + key: :max_overflow, + type: :integer, + description: "Number of additional workers if pool is under load.", + suggestions: [10] + }, + %{ + key: :timeout, + type: :integer, + description: "Timeout while `gun` will wait for response.", + suggestions: [150_000] + } + ] + }, + %{ + key: :upload, + type: :keyword, + description: "Settings for upload pool.", + children: [ + %{ + key: :size, + type: :integer, + description: "Number workers in the pool.", + suggestions: [25] + }, + %{ + key: :max_overflow, + type: :integer, + description: "Number of additional workers if pool is under load.", + suggestions: [5] + }, + %{ + key: :timeout, + type: :integer, + description: "Timeout while `gun` will wait for response.", + suggestions: [300_000] + } + ] + }, + %{ + key: :default, + type: :keyword, + description: "Settings for default pool.", + children: [ + %{ + key: :size, + type: :integer, + description: "Number workers in the pool.", + suggestions: [10] + }, + %{ + key: :max_overflow, + type: :integer, + description: "Number of additional workers if pool is under load.", + suggestions: [2] + }, + %{ + key: :timeout, + type: :integer, + description: "Timeout while `gun` will wait for response.", + suggestions: [10_000] + } + ] + } + ] + }, + %{ + group: :pleroma, + key: :hackney_pools, + type: :group, + description: "Advanced settings for `hackney` connections pools", + children: [ + %{ + key: :federation, + type: :keyword, + description: "Settings for federation pool.", + children: [ + %{ + key: :max_connections, + type: :integer, + description: "Number workers in the pool.", + suggestions: [50] + }, + %{ + key: :timeout, + type: :integer, + description: "Timeout while `hackney` will wait for response.", + suggestions: [150_000] + } + ] + }, + %{ + key: :media, + type: :keyword, + description: "Settings for media pool.", + children: [ + %{ + key: :max_connections, + type: :integer, + description: "Number workers in the pool.", + suggestions: [50] + }, + %{ + key: :timeout, + type: :integer, + description: "Timeout while `hackney` will wait for response.", + suggestions: [150_000] + } + ] + }, + %{ + key: :upload, + type: :keyword, + description: "Settings for upload pool.", + children: [ + %{ + key: :max_connections, + type: :integer, + description: "Number workers in the pool.", + suggestions: [25] + }, + %{ + key: :timeout, + type: :integer, + description: "Timeout while `hackney` will wait for response.", + suggestions: [300_000] + } + ] + } + ] } ] diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 85cc6170a..833d243e8 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -406,8 +406,8 @@ It will increase memory usage, but federation would work faster. * `:checkin_timeout` - timeout to checkin connection from pool. Default: 250ms. * `:max_connections` - maximum number of connections in the pool. Default: 250 connections. -* `:retry` - number of retries, while `gun` will try to reconnect if connections goes down. Default: 1. -* `:retry_timeout` - time between retries when gun will try to reconnect in milliseconds. Default: 1000ms. +* `:retry` - number of retries, while `gun` will try to reconnect if connection goes down. Default: 1. +* `:retry_timeout` - time between retries when `gun` will try to reconnect in milliseconds. Default: 1000ms. * `:await_up_timeout` - timeout while `gun` will wait until connection is up. Default: 5000ms. ### :pools From eb324467d9c5c761a776ffc98347246c61ad02ae Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 5 Mar 2020 09:51:52 +0300 Subject: [PATCH 068/188] removing try block in getting gun info --- lib/pleroma/pool/connections.ex | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/pool/connections.ex b/lib/pleroma/pool/connections.ex index f1fab2a24..f96c08f21 100644 --- a/lib/pleroma/pool/connections.ex +++ b/lib/pleroma/pool/connections.ex @@ -285,20 +285,15 @@ def handle_info({:DOWN, _ref, :process, conn_pid, reason}, state) do end defp compose_key_gun_info(pid) do - try do - # sometimes :gun.info can raise MatchError, which lead to pool terminate - %{origin_host: origin_host, origin_scheme: scheme, origin_port: port} = Gun.info(pid) + %{origin_host: origin_host, origin_scheme: scheme, origin_port: port} = Gun.info(pid) - host = - case :inet.ntoa(origin_host) do - {:error, :einval} -> origin_host - ip -> ip - end + host = + case :inet.ntoa(origin_host) do + {:error, :einval} -> origin_host + ip -> ip + end - "#{scheme}:#{host}:#{port}" - rescue - _ -> :error_gun_info - end + "#{scheme}:#{host}:#{port}" end defp find_conn(conns, conn_pid) do From f0753eed0fdddd30e127213c89a118dd2e087dc9 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 5 Mar 2020 17:31:06 +0300 Subject: [PATCH 069/188] removing try block in tesla request added mocks for tests which fail with Tesla.Mock.Error --- lib/pleroma/http/http.ex | 24 +++-------- lib/pleroma/pool/request.ex | 2 +- lib/pleroma/web/push/impl.ex | 2 +- lib/pleroma/web/web_finger/web_finger.ex | 3 +- test/fixtures/users_mock/localhost.json | 41 +++++++++++++++++++ test/notification_test.exs | 20 +++++++++ .../mrf/anti_link_spam_policy_test.exs | 9 ++++ test/web/activity_pub/relay_test.exs | 5 +++ .../notification_controller_test.exs | 13 ++++++ .../views/notification_view_test.exs | 13 ++++++ .../mastodon_api/views/status_view_test.exs | 17 ++++++++ test/web/streamer/streamer_test.exs | 12 ++++++ 12 files changed, 139 insertions(+), 22 deletions(-) create mode 100644 test/fixtures/users_mock/localhost.json diff --git a/lib/pleroma/http/http.ex b/lib/pleroma/http/http.ex index 7b7c79b64..466a94adc 100644 --- a/lib/pleroma/http/http.ex +++ b/lib/pleroma/http/http.ex @@ -88,15 +88,11 @@ def request(method, url, body, headers, options) when is_binary(url) do end @spec request(Client.t(), keyword(), map()) :: {:ok, Env.t()} | {:error, any()} - def request(%Client{} = client, request, %{env: :test}), do: request_try(client, request) + def request(%Client{} = client, request, %{env: :test}), do: request(client, request) - def request(%Client{} = client, request, %{body_as: :chunks}) do - request_try(client, request) - end + def request(%Client{} = client, request, %{body_as: :chunks}), do: request(client, request) - def request(%Client{} = client, request, %{pool_alive?: false}) do - request_try(client, request) - end + def request(%Client{} = client, request, %{pool_alive?: false}), do: request(client, request) def request(%Client{} = client, request, %{pool: pool, timeout: timeout}) do :poolboy.transaction( @@ -106,18 +102,8 @@ def request(%Client{} = client, request, %{pool: pool, timeout: timeout}) do ) end - @spec request_try(Client.t(), keyword()) :: {:ok, Env.t()} | {:error, any()} - def request_try(client, request) do - try do - Tesla.request(client, request) - rescue - e -> - {:error, e} - catch - :exit, e -> - {:error, e} - end - end + @spec request(Client.t(), keyword()) :: {:ok, Env.t()} | {:error, any()} + def request(client, request), do: Tesla.request(client, request) defp build_request(method, headers, options, url, body, params) do Builder.new() diff --git a/lib/pleroma/pool/request.ex b/lib/pleroma/pool/request.ex index 0f271b3d0..db7c10c01 100644 --- a/lib/pleroma/pool/request.ex +++ b/lib/pleroma/pool/request.ex @@ -22,7 +22,7 @@ def execute(pid, client, request, timeout) do @impl true def handle_call({:execute, client, request}, _from, state) do - response = Pleroma.HTTP.request_try(client, request) + response = Pleroma.HTTP.request(client, request) {:reply, response, state} end diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index afa510f08..233e55f21 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -32,7 +32,7 @@ def perform( type = Activity.mastodon_notification_type(notif.activity) gcm_api_key = Application.get_env(:web_push_encryption, :gcm_api_key) avatar_url = User.avatar_url(actor) - object = Object.normalize(activity) + object = Object.normalize(activity) || activity user = User.get_cached_by_id(user_id) direct_conversation_id = Activity.direct_conversation_id(activity, user) diff --git a/lib/pleroma/web/web_finger/web_finger.ex b/lib/pleroma/web/web_finger/web_finger.ex index db567a02e..7ffd0e51b 100644 --- a/lib/pleroma/web/web_finger/web_finger.ex +++ b/lib/pleroma/web/web_finger/web_finger.ex @@ -173,7 +173,8 @@ def find_lrdd_template(domain) do get_template_from_xml(body) else _ -> - with {:ok, %{body: body}} <- HTTP.get("https://#{domain}/.well-known/host-meta", []) do + with {:ok, %{body: body, status: status}} when status in 200..299 <- + HTTP.get("https://#{domain}/.well-known/host-meta", []) do get_template_from_xml(body) else e -> {:error, "Can't find LRDD template: #{inspect(e)}"} diff --git a/test/fixtures/users_mock/localhost.json b/test/fixtures/users_mock/localhost.json new file mode 100644 index 000000000..a49935db1 --- /dev/null +++ b/test/fixtures/users_mock/localhost.json @@ -0,0 +1,41 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "http://localhost:4001/schemas/litepub-0.1.jsonld", + { + "@language": "und" + } + ], + "attachment": [], + "endpoints": { + "oauthAuthorizationEndpoint": "http://localhost:4001/oauth/authorize", + "oauthRegistrationEndpoint": "http://localhost:4001/api/v1/apps", + "oauthTokenEndpoint": "http://localhost:4001/oauth/token", + "sharedInbox": "http://localhost:4001/inbox" + }, + "followers": "http://localhost:4001/users/{{nickname}}/followers", + "following": "http://localhost:4001/users/{{nickname}}/following", + "icon": { + "type": "Image", + "url": "http://localhost:4001/media/4e914f5b84e4a259a3f6c2d2edc9ab642f2ab05f3e3d9c52c81fc2d984b3d51e.jpg" + }, + "id": "http://localhost:4001/users/{{nickname}}", + "image": { + "type": "Image", + "url": "http://localhost:4001/media/f739efddefeee49c6e67e947c4811fdc911785c16ae43da4c3684051fbf8da6a.jpg?name=f739efddefeee49c6e67e947c4811fdc911785c16ae43da4c3684051fbf8da6a.jpg" + }, + "inbox": "http://localhost:4001/users/{{nickname}}/inbox", + "manuallyApprovesFollowers": false, + "name": "{{nickname}}", + "outbox": "http://localhost:4001/users/{{nickname}}/outbox", + "preferredUsername": "{{nickname}}", + "publicKey": { + "id": "http://localhost:4001/users/{{nickname}}#main-key", + "owner": "http://localhost:4001/users/{{nickname}}", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5DLtwGXNZElJyxFGfcVc\nXANhaMadj/iYYQwZjOJTV9QsbtiNBeIK54PJrYuU0/0YIdrvS1iqheX5IwXRhcwa\nhm3ZyLz7XeN9st7FBni4BmZMBtMpxAuYuu5p/jbWy13qAiYOhPreCx0wrWgm/lBD\n9mkgaxIxPooBE0S4ZWEJIDIV1Vft3AWcRUyWW1vIBK0uZzs6GYshbQZB952S0yo4\nFzI1hABGHncH8UvuFauh4EZ8tY7/X5I0pGRnDOcRN1dAht5w5yTA+6r5kebiFQjP\nIzN/eCO/a9Flrj9YGW7HDNtjSOH0A31PLRGlJtJO3yK57dnf5ppyCZGfL4emShQo\ncQIDAQAB\n-----END PUBLIC KEY-----\n\n" + }, + "summary": "your friendly neighborhood pleroma developer
I like cute things and distributed systems, and really hate delete and redrafts", + "tag": [], + "type": "Person", + "url": "http://localhost:4001/users/{{nickname}}" +} \ No newline at end of file diff --git a/test/notification_test.exs b/test/notification_test.exs index 56a581810..c71df4e07 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -649,12 +649,20 @@ test "notifications are deleted if a remote user is deleted" do "object" => remote_user.ap_id } + remote_user_url = remote_user.ap_id + + Tesla.Mock.mock(fn + %{method: :get, url: ^remote_user_url} -> + %Tesla.Env{status: 404, body: ""} + end) + {:ok, _delete_activity} = Transmogrifier.handle_incoming(delete_user_message) ObanHelpers.perform_all() assert Enum.empty?(Notification.for_user(local_user)) end + @tag capture_log: true test "move activity generates a notification" do %{ap_id: old_ap_id} = old_user = insert(:user) %{ap_id: new_ap_id} = new_user = insert(:user, also_known_as: [old_ap_id]) @@ -664,6 +672,18 @@ test "move activity generates a notification" do User.follow(follower, old_user) User.follow(other_follower, old_user) + old_user_url = old_user.ap_id + + body = + File.read!("test/fixtures/users_mock/localhost.json") + |> String.replace("{{nickname}}", old_user.nickname) + |> Jason.encode!() + + Tesla.Mock.mock(fn + %{method: :get, url: ^old_user_url} -> + %Tesla.Env{status: 200, body: body} + end) + Pleroma.Web.ActivityPub.ActivityPub.move(old_user, new_user) ObanHelpers.perform_all() diff --git a/test/web/activity_pub/mrf/anti_link_spam_policy_test.exs b/test/web/activity_pub/mrf/anti_link_spam_policy_test.exs index fc0be6f91..1a13699be 100644 --- a/test/web/activity_pub/mrf/anti_link_spam_policy_test.exs +++ b/test/web/activity_pub/mrf/anti_link_spam_policy_test.exs @@ -110,6 +110,15 @@ test "it allows posts with links" do end describe "with unknown actors" do + setup do + Tesla.Mock.mock(fn + %{method: :get, url: "http://invalid.actor"} -> + %Tesla.Env{status: 500, body: ""} + end) + + :ok + end + test "it rejects posts without links" do message = @linkless_message diff --git a/test/web/activity_pub/relay_test.exs b/test/web/activity_pub/relay_test.exs index e3115dcd8..12bf90d90 100644 --- a/test/web/activity_pub/relay_test.exs +++ b/test/web/activity_pub/relay_test.exs @@ -89,6 +89,11 @@ test "returns error when object is unknown" do } ) + Tesla.Mock.mock(fn + %{method: :get, url: "http://mastodon.example.org/eee/99541947525187367"} -> + %Tesla.Env{status: 500, body: ""} + end) + assert capture_log(fn -> assert Relay.publish(activity) == {:error, nil} end) =~ "[error] error: nil" diff --git a/test/web/mastodon_api/controllers/notification_controller_test.exs b/test/web/mastodon_api/controllers/notification_controller_test.exs index d452ddbdd..0f0a060d2 100644 --- a/test/web/mastodon_api/controllers/notification_controller_test.exs +++ b/test/web/mastodon_api/controllers/notification_controller_test.exs @@ -407,11 +407,24 @@ test "see notifications after muting user with notifications and with_muted para assert length(json_response(conn, 200)) == 1 end + @tag capture_log: true test "see move notifications with `with_move` parameter" do old_user = insert(:user) new_user = insert(:user, also_known_as: [old_user.ap_id]) %{user: follower, conn: conn} = oauth_access(["read:notifications"]) + old_user_url = old_user.ap_id + + body = + File.read!("test/fixtures/users_mock/localhost.json") + |> String.replace("{{nickname}}", old_user.nickname) + |> Jason.encode!() + + Tesla.Mock.mock(fn + %{method: :get, url: ^old_user_url} -> + %Tesla.Env{status: 200, body: body} + end) + User.follow(follower, old_user) Pleroma.Web.ActivityPub.ActivityPub.move(old_user, new_user) Pleroma.Tests.ObanHelpers.perform_all() diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index 4df9c3c03..57e4c8f1e 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -108,11 +108,24 @@ test "Follow notification" do NotificationView.render("index.json", %{notifications: [notification], for: followed}) end + @tag capture_log: true test "Move notification" do old_user = insert(:user) new_user = insert(:user, also_known_as: [old_user.ap_id]) follower = insert(:user) + old_user_url = old_user.ap_id + + body = + File.read!("test/fixtures/users_mock/localhost.json") + |> String.replace("{{nickname}}", old_user.nickname) + |> Jason.encode!() + + Tesla.Mock.mock(fn + %{method: :get, url: ^old_user_url} -> + %Tesla.Env{status: 200, body: body} + end) + User.follow(follower, old_user) Pleroma.Web.ActivityPub.ActivityPub.move(old_user, new_user) Pleroma.Tests.ObanHelpers.perform_all() diff --git a/test/web/mastodon_api/views/status_view_test.exs b/test/web/mastodon_api/views/status_view_test.exs index 191895c6f..7df72decb 100644 --- a/test/web/mastodon_api/views/status_view_test.exs +++ b/test/web/mastodon_api/views/status_view_test.exs @@ -92,6 +92,23 @@ test "returns a temporary ap_id based user for activities missing db users" do Repo.delete(user) Cachex.clear(:user_cache) + finger_url = + "https://localhost/.well-known/webfinger?resource=acct:#{user.nickname}@localhost" + + Tesla.Mock.mock_global(fn + %{method: :get, url: "http://localhost/.well-known/host-meta"} -> + %Tesla.Env{status: 404, body: ""} + + %{method: :get, url: "https://localhost/.well-known/host-meta"} -> + %Tesla.Env{status: 404, body: ""} + + %{ + method: :get, + url: ^finger_url + } -> + %Tesla.Env{status: 404, body: ""} + end) + %{account: ms_user} = StatusView.render("show.json", activity: activity) assert ms_user.acct == "erroruser@example.com" diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index 339f99bbf..a04d70f21 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -122,6 +122,18 @@ test "it doesn't send notify to the 'user:notification' stream' when a domain is test "it sends follow activities to the 'user:notification' stream", %{ user: user } do + user_url = user.ap_id + + body = + File.read!("test/fixtures/users_mock/localhost.json") + |> String.replace("{{nickname}}", user.nickname) + |> Jason.encode!() + + Tesla.Mock.mock_global(fn + %{method: :get, url: ^user_url} -> + %Tesla.Env{status: 200, body: body} + end) + user2 = insert(:user) task = Task.async(fn -> assert_receive {:text, _}, @streamer_timeout end) From 058c9b01ac063f3cca22a653032663916a16a234 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 5 Mar 2020 18:28:04 +0300 Subject: [PATCH 070/188] returning, not needed --- lib/pleroma/web/push/impl.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index 233e55f21..afa510f08 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -32,7 +32,7 @@ def perform( type = Activity.mastodon_notification_type(notif.activity) gcm_api_key = Application.get_env(:web_push_encryption, :gcm_api_key) avatar_url = User.avatar_url(actor) - object = Object.normalize(activity) || activity + object = Object.normalize(activity) user = User.get_cached_by_id(user_id) direct_conversation_id = Activity.direct_conversation_id(activity, user) From 931111fd5518cb79449cf79ffe29cb774c55d5ff Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 5 Mar 2020 18:57:45 +0300 Subject: [PATCH 071/188] removing integration tests --- test/http_test.exs | 25 -- test/pool/connections_test.exs | 301 ---------------------- test/reverse_proxy/client/tesla_test.exs | 93 ------- test/reverse_proxy/reverse_proxy_test.exs | 41 --- 4 files changed, 460 deletions(-) delete mode 100644 test/reverse_proxy/client/tesla_test.exs diff --git a/test/http_test.exs b/test/http_test.exs index 4aa08afcb..fd254b590 100644 --- a/test/http_test.exs +++ b/test/http_test.exs @@ -58,29 +58,4 @@ test "returns successfully result" do } end end - - describe "connection pools" do - @describetag :integration - clear_config(Pleroma.Gun) do - Pleroma.Config.put(Pleroma.Gun, Pleroma.Gun.API) - end - - test "gun" do - adapter = Application.get_env(:tesla, :adapter) - Application.put_env(:tesla, :adapter, Tesla.Adapter.Gun) - - on_exit(fn -> - Application.put_env(:tesla, :adapter, adapter) - end) - - options = [adapter: [pool: :federation]] - - assert {:ok, resp} = HTTP.get("https://httpbin.org/user-agent", [], options) - - assert resp.status == 200 - - state = Pleroma.Pool.Connections.get_state(:gun_connections) - assert state.conns["https:httpbin.org:443"] - end - end end diff --git a/test/pool/connections_test.exs b/test/pool/connections_test.exs index 963fae665..753fd8b0b 100644 --- a/test/pool/connections_test.exs +++ b/test/pool/connections_test.exs @@ -435,307 +435,6 @@ test "remove frequently used and idle", %{name: name} do } = Connections.get_state(name) end - describe "integration test" do - @describetag :integration - - clear_config(Pleroma.Gun) do - Pleroma.Config.put(Pleroma.Gun, Pleroma.Gun.API) - end - - test "opens connection and change owner", %{name: name} do - url = "https://httpbin.org" - :ok = Conn.open(url, name) - conn = Connections.checkin(url, name) - - pid = Process.whereis(name) - - assert :gun.info(conn).owner == pid - end - - test "opens connection and reuse it on next request", %{name: name} do - url = "http://httpbin.org" - :ok = Conn.open(url, name) - Process.sleep(250) - conn = Connections.checkin(url, name) - - assert is_pid(conn) - assert Process.alive?(conn) - - reused_conn = Connections.checkin(url, name) - - assert conn == reused_conn - - %Connections{ - conns: %{ - "http:httpbin.org:80" => %Conn{ - conn: ^conn, - gun_state: :up - } - } - } = Connections.get_state(name) - end - - test "opens ssl connection and reuse it on next request", %{name: name} do - url = "https://httpbin.org" - :ok = Conn.open(url, name) - Process.sleep(1_000) - conn = Connections.checkin(url, name) - - assert is_pid(conn) - assert Process.alive?(conn) - - reused_conn = Connections.checkin(url, name) - - assert conn == reused_conn - - %Connections{ - conns: %{ - "https:httpbin.org:443" => %Conn{ - conn: ^conn, - gun_state: :up - } - } - } = Connections.get_state(name) - end - - test "remove frequently used and idle", %{name: name} do - self = self() - https1 = "https://www.google.com" - https2 = "https://httpbin.org" - - :ok = Conn.open(https1, name) - :ok = Conn.open(https2, name) - Process.sleep(1_500) - conn = Connections.checkin(https1, name) - - for _ <- 1..4 do - Connections.checkin(https2, name) - end - - %Connections{ - conns: %{ - "https:httpbin.org:443" => %Conn{ - conn: _, - gun_state: :up - }, - "https:www.google.com:443" => %Conn{ - conn: _, - gun_state: :up - } - } - } = Connections.get_state(name) - - :ok = Connections.checkout(conn, self, name) - http = "http://httpbin.org" - Process.sleep(1_000) - :ok = Conn.open(http, name) - conn = Connections.checkin(http, name) - - %Connections{ - conns: %{ - "http:httpbin.org:80" => %Conn{ - conn: ^conn, - gun_state: :up - }, - "https:httpbin.org:443" => %Conn{ - conn: _, - gun_state: :up - } - } - } = Connections.get_state(name) - end - - test "remove earlier used and idle", %{name: name} do - self = self() - - https1 = "https://www.google.com" - https2 = "https://httpbin.org" - :ok = Conn.open(https1, name) - :ok = Conn.open(https2, name) - Process.sleep(1_500) - - Connections.checkin(https1, name) - conn = Connections.checkin(https1, name) - - Process.sleep(1_000) - Connections.checkin(https2, name) - Connections.checkin(https2, name) - - %Connections{ - conns: %{ - "https:httpbin.org:443" => %Conn{ - conn: _, - gun_state: :up - }, - "https:www.google.com:443" => %Conn{ - conn: ^conn, - gun_state: :up - } - } - } = Connections.get_state(name) - - :ok = Connections.checkout(conn, self, name) - :ok = Connections.checkout(conn, self, name) - - http = "http://httpbin.org" - :ok = Conn.open(http, name) - Process.sleep(1_000) - - conn = Connections.checkin(http, name) - - %Connections{ - conns: %{ - "http:httpbin.org:80" => %Conn{ - conn: ^conn, - gun_state: :up - }, - "https:httpbin.org:443" => %Conn{ - conn: _, - gun_state: :up - } - } - } = Connections.get_state(name) - end - - test "doesn't open new conn on pool overflow", %{name: name} do - self = self() - - https1 = "https://www.google.com" - https2 = "https://httpbin.org" - :ok = Conn.open(https1, name) - :ok = Conn.open(https2, name) - Process.sleep(1_000) - Connections.checkin(https1, name) - conn1 = Connections.checkin(https1, name) - conn2 = Connections.checkin(https2, name) - - %Connections{ - conns: %{ - "https:httpbin.org:443" => %Conn{ - conn: ^conn2, - gun_state: :up, - conn_state: :active, - used_by: [{^self, _}] - }, - "https:www.google.com:443" => %Conn{ - conn: ^conn1, - gun_state: :up, - conn_state: :active, - used_by: [{^self, _}, {^self, _}] - } - } - } = Connections.get_state(name) - - refute Connections.checkin("http://httpbin.org", name) - - %Connections{ - conns: %{ - "https:httpbin.org:443" => %Conn{ - conn: ^conn2, - gun_state: :up, - conn_state: :active, - used_by: [{^self, _}] - }, - "https:www.google.com:443" => %Conn{ - conn: ^conn1, - gun_state: :up, - conn_state: :active, - used_by: [{^self, _}, {^self, _}] - } - } - } = Connections.get_state(name) - end - - test "get idle connection with the smallest crf", %{ - name: name - } do - self = self() - - https1 = "https://www.google.com" - https2 = "https://httpbin.org" - - :ok = Conn.open(https1, name) - :ok = Conn.open(https2, name) - Process.sleep(1_500) - Connections.checkin(https1, name) - Connections.checkin(https2, name) - Connections.checkin(https1, name) - conn1 = Connections.checkin(https1, name) - conn2 = Connections.checkin(https2, name) - - %Connections{ - conns: %{ - "https:httpbin.org:443" => %Conn{ - conn: ^conn2, - gun_state: :up, - conn_state: :active, - used_by: [{^self, _}, {^self, _}], - crf: crf2 - }, - "https:www.google.com:443" => %Conn{ - conn: ^conn1, - gun_state: :up, - conn_state: :active, - used_by: [{^self, _}, {^self, _}, {^self, _}], - crf: crf1 - } - } - } = Connections.get_state(name) - - assert crf1 > crf2 - - :ok = Connections.checkout(conn1, self, name) - :ok = Connections.checkout(conn1, self, name) - :ok = Connections.checkout(conn1, self, name) - - :ok = Connections.checkout(conn2, self, name) - :ok = Connections.checkout(conn2, self, name) - - %Connections{ - conns: %{ - "https:httpbin.org:443" => %Conn{ - conn: ^conn2, - gun_state: :up, - conn_state: :idle, - used_by: [] - }, - "https:www.google.com:443" => %Conn{ - conn: ^conn1, - gun_state: :up, - conn_state: :idle, - used_by: [] - } - } - } = Connections.get_state(name) - - http = "http://httpbin.org" - :ok = Conn.open(http, name) - Process.sleep(1_000) - conn = Connections.checkin(http, name) - - %Connections{ - conns: %{ - "https:www.google.com:443" => %Conn{ - conn: ^conn1, - gun_state: :up, - conn_state: :idle, - used_by: [], - crf: crf1 - }, - "http:httpbin.org:80" => %Conn{ - conn: ^conn, - gun_state: :up, - conn_state: :active, - used_by: [{^self, _}], - crf: crf - } - } - } = Connections.get_state(name) - - assert crf1 > crf - end - end - describe "with proxy" do test "as ip", %{name: name} do url = "http://proxy-string.com" diff --git a/test/reverse_proxy/client/tesla_test.exs b/test/reverse_proxy/client/tesla_test.exs deleted file mode 100644 index c8b0d5842..000000000 --- a/test/reverse_proxy/client/tesla_test.exs +++ /dev/null @@ -1,93 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.ReverseProxy.Client.TeslaTest do - use ExUnit.Case - use Pleroma.Tests.Helpers - alias Pleroma.ReverseProxy.Client - @moduletag :integration - - clear_config_all(Pleroma.Gun) do - Pleroma.Config.put(Pleroma.Gun, Pleroma.Gun.API) - end - - setup do - Application.put_env(:tesla, :adapter, Tesla.Adapter.Gun) - - on_exit(fn -> - Application.put_env(:tesla, :adapter, Tesla.Mock) - end) - end - - test "get response body stream" do - {:ok, status, headers, ref} = - Client.Tesla.request( - :get, - "http://httpbin.org/stream-bytes/10", - [{"accept", "application/octet-stream"}], - "", - [] - ) - - assert status == 200 - assert headers != [] - - {:ok, response, ref} = Client.Tesla.stream_body(ref) - check_ref(ref) - assert is_binary(response) - assert byte_size(response) == 10 - - assert :done == Client.Tesla.stream_body(ref) - assert :ok = Client.Tesla.close(ref) - end - - test "head response" do - {:ok, status, headers} = Client.Tesla.request(:head, "https://httpbin.org/get", [], "") - - assert status == 200 - assert headers != [] - end - - test "get error response" do - {:ok, status, headers, _body} = - Client.Tesla.request( - :get, - "https://httpbin.org/status/500", - [], - "" - ) - - assert status == 500 - assert headers != [] - end - - describe "client error" do - setup do - adapter = Application.get_env(:tesla, :adapter) - Application.put_env(:tesla, :adapter, Tesla.Adapter.Hackney) - - on_exit(fn -> Application.put_env(:tesla, :adapter, adapter) end) - :ok - end - - test "adapter doesn't support reading body in chunks" do - assert_raise RuntimeError, - "Elixir.Tesla.Adapter.Hackney doesn't support reading body in chunks", - fn -> - Client.Tesla.request( - :get, - "http://httpbin.org/stream-bytes/10", - [{"accept", "application/octet-stream"}], - "" - ) - end - end - end - - defp check_ref(%{pid: pid, stream: stream} = ref) do - assert is_pid(pid) - assert is_reference(stream) - assert ref[:fin] - end -end diff --git a/test/reverse_proxy/reverse_proxy_test.exs b/test/reverse_proxy/reverse_proxy_test.exs index 18aae5a6b..c17ab0f89 100644 --- a/test/reverse_proxy/reverse_proxy_test.exs +++ b/test/reverse_proxy/reverse_proxy_test.exs @@ -341,45 +341,4 @@ test "with content-disposition header", %{conn: conn} do assert {"content-disposition", "attachment; filename=\"filename.jpg\""} in conn.resp_headers end end - - describe "tesla client using gun integration" do - @describetag :integration - - clear_config(Pleroma.ReverseProxy.Client) do - Pleroma.Config.put(Pleroma.ReverseProxy.Client, Pleroma.ReverseProxy.Client.Tesla) - end - - clear_config(Pleroma.Gun) do - Pleroma.Config.put(Pleroma.Gun, Pleroma.Gun.API) - end - - setup do - adapter = Application.get_env(:tesla, :adapter) - Application.put_env(:tesla, :adapter, Tesla.Adapter.Gun) - - on_exit(fn -> - Application.put_env(:tesla, :adapter, adapter) - end) - end - - test "common", %{conn: conn} do - conn = ReverseProxy.call(conn, "http://httpbin.org/stream-bytes/10") - assert byte_size(conn.resp_body) == 10 - assert conn.state == :chunked - assert conn.status == 200 - end - - test "ssl", %{conn: conn} do - conn = ReverseProxy.call(conn, "https://httpbin.org/stream-bytes/10") - assert byte_size(conn.resp_body) == 10 - assert conn.state == :chunked - assert conn.status == 200 - end - - test "follow redirects", %{conn: conn} do - conn = ReverseProxy.call(conn, "https://httpbin.org/redirect/5") - assert conn.state == :chunked - assert conn.status == 200 - end - end end From 56ff02f2ef56465b14c9670b930d154911cc7470 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 6 Mar 2020 20:23:58 +0300 Subject: [PATCH 072/188] removing GunMock to use Mox --- test/http/adapter_helper/gun_test.exs | 88 ++++++------ test/http/adapter_helper/hackney_test.exs | 8 +- test/http/connection_test.exs | 25 ++-- test/pool/connections_test.exs | 127 ++++++++++++++---- test/support/gun_mock.ex | 155 ---------------------- test/test_helper.exs | 3 + 6 files changed, 172 insertions(+), 234 deletions(-) delete mode 100644 test/support/gun_mock.ex diff --git a/test/http/adapter_helper/gun_test.exs b/test/http/adapter_helper/gun_test.exs index c1bf909a6..b1b34858a 100644 --- a/test/http/adapter_helper/gun_test.exs +++ b/test/http/adapter_helper/gun_test.exs @@ -5,17 +5,29 @@ defmodule Pleroma.HTTP.AdapterHelper.GunTest do use ExUnit.Case, async: true use Pleroma.Tests.Helpers + import ExUnit.CaptureLog + import Mox + alias Pleroma.Config alias Pleroma.Gun.Conn alias Pleroma.HTTP.AdapterHelper.Gun alias Pleroma.Pool.Connections - setup_all do - {:ok, _} = Registry.start_link(keys: :unique, name: Pleroma.GunMock) + setup :verify_on_exit! + + defp gun_mock(_) do + gun_mock() :ok end + defp gun_mock do + Pleroma.GunMock + |> expect(:open, fn _, _, _ -> Task.start_link(fn -> Process.sleep(1000) end) end) + |> expect(:await_up, fn _, _ -> {:ok, :http} end) + |> expect(:set_owner, fn _, _ -> :ok end) + end + describe "options/1" do clear_config([:http, :adapter]) do Config.put([:http, :adapter], a: 1, b: 2) @@ -24,23 +36,20 @@ defmodule Pleroma.HTTP.AdapterHelper.GunTest do test "https url with default port" do uri = URI.parse("https://example.com") - opts = Gun.options(uri) + opts = Gun.options([receive_conn: false], uri) assert opts[:certificates_verification] - tls_opts = opts[:tls_opts] - assert tls_opts[:verify] == :verify_peer - assert tls_opts[:depth] == 20 - assert tls_opts[:reuse_sessions] == false + refute opts[:tls_opts] == [] - assert tls_opts[:verify_fun] == + assert opts[:tls_opts][:verify_fun] == {&:ssl_verify_hostname.verify_fun/3, [check_hostname: 'example.com']} - assert File.exists?(tls_opts[:cacertfile]) + assert File.exists?(opts[:tls_opts][:cacertfile]) end test "https ipv4 with default port" do uri = URI.parse("https://127.0.0.1") - opts = Gun.options(uri) + opts = Gun.options([receive_conn: false], uri) assert opts[:tls_opts][:verify_fun] == {&:ssl_verify_hostname.verify_fun/3, [check_hostname: '127.0.0.1']} @@ -49,7 +58,7 @@ test "https ipv4 with default port" do test "https ipv6 with default port" do uri = URI.parse("https://[2a03:2880:f10c:83:face:b00c:0:25de]") - opts = Gun.options(uri) + opts = Gun.options([receive_conn: false], uri) assert opts[:tls_opts][:verify_fun] == {&:ssl_verify_hostname.verify_fun/3, @@ -59,32 +68,14 @@ test "https ipv6 with default port" do test "https url with non standart port" do uri = URI.parse("https://example.com:115") - opts = Gun.options(uri) + opts = Gun.options([receive_conn: false], uri) assert opts[:certificates_verification] assert opts[:transport] == :tls end - test "receive conn by default" do - uri = URI.parse("http://another-domain.com") - :ok = Conn.open(uri, :gun_connections) - - received_opts = Gun.options(uri) - assert received_opts[:close_conn] == false - assert is_pid(received_opts[:conn]) - end - - test "don't receive conn if receive_conn is false" do - uri = URI.parse("http://another-domain2.com") - :ok = Conn.open(uri, :gun_connections) - - opts = [receive_conn: false] - received_opts = Gun.options(opts, uri) - assert received_opts[:close_conn] == nil - assert received_opts[:conn] == nil - end - test "get conn on next request" do + gun_mock() level = Application.get_env(:logger, :level) Logger.configure(level: :debug) on_exit(fn -> Logger.configure(level: level) end) @@ -105,12 +96,13 @@ test "get conn on next request" do end test "merges with defaul http adapter config" do - defaults = Gun.options(URI.parse("https://example.com")) + defaults = Gun.options([receive_conn: false], URI.parse("https://example.com")) assert Keyword.has_key?(defaults, :a) assert Keyword.has_key?(defaults, :b) end test "default ssl adapter opts with connection" do + gun_mock() uri = URI.parse("https://some-domain.com") :ok = Conn.open(uri, :gun_connections) @@ -118,10 +110,7 @@ test "default ssl adapter opts with connection" do opts = Gun.options(uri) assert opts[:certificates_verification] - tls_opts = opts[:tls_opts] - assert tls_opts[:verify] == :verify_peer - assert tls_opts[:depth] == 20 - assert tls_opts[:reuse_sessions] == false + refute opts[:tls_opts] == [] assert opts[:close_conn] == false assert is_pid(opts[:conn]) @@ -158,7 +147,32 @@ test "passed opts have more weight than defaults" do end end + describe "options/1 with receive_conn parameter" do + setup :gun_mock + + test "receive conn by default" do + uri = URI.parse("http://another-domain.com") + :ok = Conn.open(uri, :gun_connections) + + received_opts = Gun.options(uri) + assert received_opts[:close_conn] == false + assert is_pid(received_opts[:conn]) + end + + test "don't receive conn if receive_conn is false" do + uri = URI.parse("http://another-domain.com") + :ok = Conn.open(uri, :gun_connections) + + opts = [receive_conn: false] + received_opts = Gun.options(opts, uri) + assert received_opts[:close_conn] == nil + assert received_opts[:conn] == nil + end + end + describe "after_request/1" do + setup :gun_mock + test "body_as not chunks" do uri = URI.parse("http://some-domain.com") :ok = Conn.open(uri, :gun_connections) @@ -223,7 +237,6 @@ test "with ipv4" do uri = URI.parse("http://127.0.0.1") :ok = Conn.open(uri, :gun_connections) opts = Gun.options(uri) - send(:gun_connections, {:gun_up, opts[:conn], :http}) :ok = Gun.after_request(opts) conn = opts[:conn] @@ -242,7 +255,6 @@ test "with ipv6" do uri = URI.parse("http://[2a03:2880:f10c:83:face:b00c:0:25de]") :ok = Conn.open(uri, :gun_connections) opts = Gun.options(uri) - send(:gun_connections, {:gun_up, opts[:conn], :http}) :ok = Gun.after_request(opts) conn = opts[:conn] diff --git a/test/http/adapter_helper/hackney_test.exs b/test/http/adapter_helper/hackney_test.exs index 3306616ef..5fda075f6 100644 --- a/test/http/adapter_helper/hackney_test.exs +++ b/test/http/adapter_helper/hackney_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.HTTP.AdapterHelper.HackneyTest do - use ExUnit.Case + use ExUnit.Case, async: true use Pleroma.Tests.Helpers alias Pleroma.Config @@ -20,11 +20,7 @@ defmodule Pleroma.HTTP.AdapterHelper.HackneyTest do end test "add proxy and opts from config", %{uri: uri} do - proxy = Config.get([:http, :proxy_url]) - Config.put([:http, :proxy_url], "localhost:8123") - on_exit(fn -> Config.put([:http, :proxy_url], proxy) end) - - opts = Hackney.options(uri) + opts = Hackney.options([proxy: "localhost:8123"], uri) assert opts[:a] == 1 assert opts[:b] == 2 diff --git a/test/http/connection_test.exs b/test/http/connection_test.exs index d4db3798c..a5ddfd435 100644 --- a/test/http/connection_test.exs +++ b/test/http/connection_test.exs @@ -3,16 +3,16 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.HTTP.ConnectionTest do - use ExUnit.Case + use ExUnit.Case, async: true use Pleroma.Tests.Helpers + import ExUnit.CaptureLog + import Mox + alias Pleroma.Config alias Pleroma.HTTP.Connection - setup_all do - {:ok, _} = Registry.start_link(keys: :unique, name: Pleroma.GunMock) - :ok - end + setup :verify_on_exit! describe "parse_host/1" do test "as atom to charlist" do @@ -123,16 +123,19 @@ test "default ssl adapter opts with connection" do uri = URI.parse("https://some-domain.com") - pid = Process.whereis(:federation) - :ok = Pleroma.Gun.Conn.open(uri, :gun_connections, genserver_pid: pid) + Pleroma.GunMock + |> expect(:open, fn 'some-domain.com', 443, _ -> + Task.start_link(fn -> Process.sleep(1000) end) + end) + |> expect(:await_up, fn _, _ -> {:ok, :http2} end) + |> expect(:set_owner, fn _, _ -> :ok end) + + :ok = Pleroma.Gun.Conn.open(uri, :gun_connections) opts = Connection.options(uri) assert opts[:certificates_verification] - tls_opts = opts[:tls_opts] - assert tls_opts[:verify] == :verify_peer - assert tls_opts[:depth] == 20 - assert tls_opts[:reuse_sessions] == false + refute opts[:tls_opts] == [] assert opts[:close_conn] == false assert is_pid(opts[:conn]) diff --git a/test/pool/connections_test.exs b/test/pool/connections_test.exs index 753fd8b0b..06f32b74e 100644 --- a/test/pool/connections_test.exs +++ b/test/pool/connections_test.exs @@ -3,39 +3,83 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Pool.ConnectionsTest do - use ExUnit.Case + use ExUnit.Case, async: true use Pleroma.Tests.Helpers + import ExUnit.CaptureLog + import Mox + alias Pleroma.Gun.Conn + alias Pleroma.GunMock alias Pleroma.Pool.Connections + setup :verify_on_exit! + setup_all do - {:ok, _} = Registry.start_link(keys: :unique, name: Pleroma.GunMock) - :ok - end - - clear_config([:connections_pool, :retry]) do - Pleroma.Config.put([:connections_pool, :retry], 5) - end - - setup do name = :test_connections - adapter = Application.get_env(:tesla, :adapter) - Application.put_env(:tesla, :adapter, Tesla.Adapter.Gun) - - {:ok, pid} = Connections.start_link({name, [max_connections: 2, checkin_timeout: 1_500]}) + {:ok, pid} = Connections.start_link({name, [checkin_timeout: 150]}) + {:ok, _} = Registry.start_link(keys: :unique, name: Pleroma.GunMock) on_exit(fn -> - Application.put_env(:tesla, :adapter, adapter) - - if Process.alive?(pid) do - GenServer.stop(name) - end + if Process.alive?(pid), do: GenServer.stop(name) end) {:ok, name: name} end + defp open_mock(num \\ 1) do + GunMock + |> expect(:open, num, &start_and_register(&1, &2, &3)) + |> expect(:await_up, num, fn _, _ -> {:ok, :http} end) + |> expect(:set_owner, num, fn _, _ -> :ok end) + end + + defp connect_mock(mock) do + mock + |> expect(:connect, &connect(&1, &2)) + |> expect(:await, &await(&1, &2)) + end + + defp info_mock(mock), do: expect(mock, :info, &info(&1)) + + defp start_and_register('gun-not-up.com', _, _), do: {:error, :timeout} + + defp start_and_register(host, port, _) do + {:ok, pid} = Task.start_link(fn -> Process.sleep(1000) end) + + scheme = + case port do + 443 -> "https" + _ -> "http" + end + + Registry.register(GunMock, pid, %{ + origin_scheme: scheme, + origin_host: host, + origin_port: port + }) + + {:ok, pid} + end + + defp info(pid) do + [{_, info}] = Registry.lookup(GunMock, pid) + info + end + + defp connect(pid, _) do + ref = make_ref() + Registry.register(GunMock, ref, pid) + ref + end + + defp await(pid, ref) do + [{_, ^pid}] = Registry.lookup(GunMock, ref) + {:response, :fin, 200, []} + end + + defp now, do: :os.system_time(:second) + describe "alive?/2" do test "is alive", %{name: name} do assert Connections.alive?(name) @@ -47,6 +91,7 @@ test "returns false if not started" do end test "opens connection and reuse it on next request", %{name: name} do + open_mock() url = "http://some-domain.com" key = "http:some-domain.com:80" refute Connections.checkin(url, name) @@ -112,6 +157,7 @@ test "opens connection and reuse it on next request", %{name: name} do end test "reuse connection for idna domains", %{name: name} do + open_mock() url = "http://ですsome-domain.com" refute Connections.checkin(url, name) @@ -140,6 +186,7 @@ test "reuse connection for idna domains", %{name: name} do end test "reuse for ipv4", %{name: name} do + open_mock() url = "http://127.0.0.1" refute Connections.checkin(url, name) @@ -183,6 +230,7 @@ test "reuse for ipv4", %{name: name} do end test "reuse for ipv6", %{name: name} do + open_mock() url = "http://[2a03:2880:f10c:83:face:b00c:0:25de]" refute Connections.checkin(url, name) @@ -212,6 +260,10 @@ test "reuse for ipv6", %{name: name} do end test "up and down ipv4", %{name: name} do + open_mock() + |> info_mock() + |> allow(self(), name) + self = self() url = "http://127.0.0.1" :ok = Conn.open(url, name) @@ -233,6 +285,11 @@ test "up and down ipv4", %{name: name} do test "up and down ipv6", %{name: name} do self = self() + + open_mock() + |> info_mock() + |> allow(self, name) + url = "http://[2a03:2880:f10c:83:face:b00c:0:25de]" :ok = Conn.open(url, name) conn = Connections.checkin(url, name) @@ -252,6 +309,7 @@ test "up and down ipv6", %{name: name} do end test "reuses connection based on protocol", %{name: name} do + open_mock(2) http_url = "http://some-domain.com" http_key = "http:some-domain.com:80" https_url = "https://some-domain.com" @@ -290,6 +348,7 @@ test "reuses connection based on protocol", %{name: name} do end test "connection can't get up", %{name: name} do + expect(GunMock, :open, &start_and_register(&1, &2, &3)) url = "http://gun-not-up.com" assert capture_log(fn -> @@ -301,6 +360,11 @@ test "connection can't get up", %{name: name} do test "process gun_down message and then gun_up", %{name: name} do self = self() + + open_mock() + |> info_mock() + |> allow(self, name) + url = "http://gun-down-and-up.com" key = "http:gun-down-and-up.com:80" :ok = Conn.open(url, name) @@ -351,6 +415,7 @@ test "process gun_down message and then gun_up", %{name: name} do end test "async processes get same conn for same domain", %{name: name} do + open_mock() url = "http://some-domain.com" :ok = Conn.open(url, name) @@ -383,6 +448,7 @@ test "async processes get same conn for same domain", %{name: name} do end test "remove frequently used and idle", %{name: name} do + open_mock(3) self = self() http_url = "http://some-domain.com" https_url = "https://some-domain.com" @@ -437,6 +503,9 @@ test "remove frequently used and idle", %{name: name} do describe "with proxy" do test "as ip", %{name: name} do + open_mock() + |> connect_mock() + url = "http://proxy-string.com" key = "http:proxy-string.com:80" :ok = Conn.open(url, name, proxy: {{127, 0, 0, 1}, 8123}) @@ -458,6 +527,9 @@ test "as ip", %{name: name} do end test "as host", %{name: name} do + open_mock() + |> connect_mock() + url = "http://proxy-tuple-atom.com" :ok = Conn.open(url, name, proxy: {'localhost', 9050}) conn = Connections.checkin(url, name) @@ -477,6 +549,9 @@ test "as host", %{name: name} do end test "as ip and ssl", %{name: name} do + open_mock() + |> connect_mock() + url = "https://proxy-string.com" :ok = Conn.open(url, name, proxy: {{127, 0, 0, 1}, 8123}) @@ -497,6 +572,9 @@ test "as ip and ssl", %{name: name} do end test "as host and ssl", %{name: name} do + open_mock() + |> connect_mock() + url = "https://proxy-tuple-atom.com" :ok = Conn.open(url, name, proxy: {'localhost', 9050}) conn = Connections.checkin(url, name) @@ -516,6 +594,8 @@ test "as host and ssl", %{name: name} do end test "with socks type", %{name: name} do + open_mock() + url = "http://proxy-socks.com" :ok = Conn.open(url, name, proxy: {:socks5, 'localhost', 1234}) @@ -537,6 +617,7 @@ test "with socks type", %{name: name} do end test "with socks4 type and ssl", %{name: name} do + open_mock() url = "https://proxy-socks.com" :ok = Conn.open(url, name, proxy: {:socks4, 'localhost', 1234}) @@ -667,15 +748,13 @@ test "lower crf and lower reference", %{name: name} do end end - test "count/1", %{name: name} do + test "count/1" do + name = :test_count + {:ok, _} = Connections.start_link({name, [checkin_timeout: 150]}) assert Connections.count(name) == 0 Connections.add_conn(name, "1", %Conn{conn: self()}) assert Connections.count(name) == 1 Connections.remove_conn(name, "1") assert Connections.count(name) == 0 end - - defp now do - :os.system_time(:second) - end end diff --git a/test/support/gun_mock.ex b/test/support/gun_mock.ex deleted file mode 100644 index 9d664e366..000000000 --- a/test/support/gun_mock.ex +++ /dev/null @@ -1,155 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.GunMock do - @behaviour Pleroma.Gun - - alias Pleroma.Gun - alias Pleroma.GunMock - - @impl Gun - def open('some-domain.com', 443, _) do - {:ok, conn_pid} = Task.start_link(fn -> Process.sleep(1_000) end) - - Registry.register(GunMock, conn_pid, %{ - origin_scheme: "https", - origin_host: 'some-domain.com', - origin_port: 443 - }) - - {:ok, conn_pid} - end - - @impl Gun - def open(ip, port, _) - when ip in [{10_755, 10_368, 61_708, 131, 64_206, 45_068, 0, 9_694}, {127, 0, 0, 1}] and - port in [80, 443] do - {:ok, conn_pid} = Task.start_link(fn -> Process.sleep(1_000) end) - - scheme = if port == 443, do: "https", else: "http" - - Registry.register(GunMock, conn_pid, %{ - origin_scheme: scheme, - origin_host: ip, - origin_port: port - }) - - {:ok, conn_pid} - end - - @impl Gun - def open('localhost', 1234, %{ - protocols: [:socks], - proxy: {:socks5, 'localhost', 1234}, - socks_opts: %{host: 'proxy-socks.com', port: 80, version: 5} - }) do - {:ok, conn_pid} = Task.start_link(fn -> Process.sleep(1_000) end) - - Registry.register(GunMock, conn_pid, %{ - origin_scheme: "http", - origin_host: 'proxy-socks.com', - origin_port: 80 - }) - - {:ok, conn_pid} - end - - @impl Gun - def open('localhost', 1234, %{ - protocols: [:socks], - proxy: {:socks4, 'localhost', 1234}, - socks_opts: %{ - host: 'proxy-socks.com', - port: 443, - protocols: [:http2], - tls_opts: [], - transport: :tls, - version: 4 - } - }) do - {:ok, conn_pid} = Task.start_link(fn -> Process.sleep(1_000) end) - - Registry.register(GunMock, conn_pid, %{ - origin_scheme: "https", - origin_host: 'proxy-socks.com', - origin_port: 443 - }) - - {:ok, conn_pid} - end - - @impl Gun - def open('gun-not-up.com', 80, _opts), do: {:error, :timeout} - - @impl Gun - def open('example.com', port, _) when port in [443, 115] do - {:ok, conn_pid} = Task.start_link(fn -> Process.sleep(1_000) end) - - Registry.register(GunMock, conn_pid, %{ - origin_scheme: "https", - origin_host: 'example.com', - origin_port: 443 - }) - - {:ok, conn_pid} - end - - @impl Gun - def open(domain, 80, _) do - {:ok, conn_pid} = Task.start_link(fn -> Process.sleep(1_000) end) - - Registry.register(GunMock, conn_pid, %{ - origin_scheme: "http", - origin_host: domain, - origin_port: 80 - }) - - {:ok, conn_pid} - end - - @impl Gun - def open({127, 0, 0, 1}, 8123, _) do - Task.start_link(fn -> Process.sleep(1_000) end) - end - - @impl Gun - def open('localhost', 9050, _) do - Task.start_link(fn -> Process.sleep(1_000) end) - end - - @impl Gun - def await_up(_pid, _timeout), do: {:ok, :http} - - @impl Gun - def set_owner(_pid, _owner), do: :ok - - @impl Gun - def connect(pid, %{host: _, port: 80}) do - ref = make_ref() - Registry.register(GunMock, ref, pid) - ref - end - - @impl Gun - def connect(pid, %{host: _, port: 443, protocols: [:http2], transport: :tls}) do - ref = make_ref() - Registry.register(GunMock, ref, pid) - ref - end - - @impl Gun - def await(pid, ref) do - [{_, ^pid}] = Registry.lookup(GunMock, ref) - {:response, :fin, 200, []} - end - - @impl Gun - def info(pid) do - [{_, info}] = Registry.lookup(GunMock, pid) - info - end - - @impl Gun - def close(_pid), do: :ok -end diff --git a/test/test_helper.exs b/test/test_helper.exs index 6b91d2b46..ee880e226 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -6,7 +6,10 @@ ExUnit.start(exclude: [:federated | os_exclude]) Ecto.Adapters.SQL.Sandbox.mode(Pleroma.Repo, :manual) + Mox.defmock(Pleroma.ReverseProxy.ClientMock, for: Pleroma.ReverseProxy.Client) +Mox.defmock(Pleroma.GunMock, for: Pleroma.Gun) + {:ok, _} = Application.ensure_all_started(:ex_machina) ExUnit.after_suite(fn _results -> From c93c3096d5ffb2df1493f2b8e3f0627d9a8c5910 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 6 Mar 2020 21:04:18 +0300 Subject: [PATCH 073/188] little refactor --- lib/pleroma/gun/gun.ex | 6 ++++-- lib/pleroma/http/adapter_helper/gun.ex | 18 ++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/lib/pleroma/gun/gun.ex b/lib/pleroma/gun/gun.ex index 81855e89e..4043e4880 100644 --- a/lib/pleroma/gun/gun.ex +++ b/lib/pleroma/gun/gun.ex @@ -11,6 +11,10 @@ defmodule Pleroma.Gun do @callback await(pid(), reference()) :: {:response, :fin, 200, []} @callback set_owner(pid(), pid()) :: :ok + @api Pleroma.Config.get([Pleroma.Gun], Pleroma.Gun.API) + + defp api, do: @api + def open(host, port, opts), do: api().open(host, port, opts) def info(pid), do: api().info(pid) @@ -24,6 +28,4 @@ def connect(pid, opts), do: api().connect(pid, opts) def await(pid, ref), do: api().await(pid, ref) def set_owner(pid, owner), do: api().set_owner(pid, owner) - - defp api, do: Pleroma.Config.get([Pleroma.Gun], Pleroma.Gun.API) end diff --git a/lib/pleroma/http/adapter_helper/gun.ex b/lib/pleroma/http/adapter_helper/gun.ex index 5d5870d90..9b03f4653 100644 --- a/lib/pleroma/http/adapter_helper/gun.ex +++ b/lib/pleroma/http/adapter_helper/gun.ex @@ -5,10 +5,9 @@ defmodule Pleroma.HTTP.AdapterHelper.Gun do @behaviour Pleroma.HTTP.AdapterHelper - alias Pleroma.HTTP.AdapterHelper - require Logger + alias Pleroma.HTTP.AdapterHelper alias Pleroma.Pool.Connections @defaults [ @@ -22,20 +21,23 @@ defmodule Pleroma.HTTP.AdapterHelper.Gun do @spec options(keyword(), URI.t()) :: keyword() def options(connection_opts \\ [], %URI{} = uri) do - proxy = Pleroma.Config.get([:http, :proxy_url], nil) + formatted_proxy = + Pleroma.Config.get([:http, :proxy_url], nil) + |> AdapterHelper.format_proxy() + + config_opts = Pleroma.Config.get([:http, :adapter], []) @defaults - |> Keyword.merge(Pleroma.Config.get([:http, :adapter], [])) + |> Keyword.merge(config_opts) |> add_scheme_opts(uri) - |> AdapterHelper.maybe_add_proxy(AdapterHelper.format_proxy(proxy)) + |> AdapterHelper.maybe_add_proxy(formatted_proxy) |> maybe_get_conn(uri, connection_opts) end @spec after_request(keyword()) :: :ok def after_request(opts) do - with conn when not is_nil(conn) <- opts[:conn], - body_as when body_as != :chunks <- opts[:body_as] do - Connections.checkout(conn, self(), :gun_connections) + if opts[:conn] && opts[:body_as] != :chunks do + Connections.checkout(opts[:conn], self(), :gun_connections) end :ok From 78282dc9839dbd17c4649cd3936bb8f4c8283745 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 6 Mar 2020 21:24:19 +0300 Subject: [PATCH 074/188] little polishing --- lib/pleroma/http/adapter_helper/gun.ex | 4 ++-- lib/pleroma/http/adapter_helper/hackney.ex | 4 +++- lib/pleroma/http/connection.ex | 15 ++++++++------- lib/pleroma/pool/connections.ex | 3 +-- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/http/adapter_helper/gun.ex b/lib/pleroma/http/adapter_helper/gun.ex index 9b03f4653..862e851c0 100644 --- a/lib/pleroma/http/adapter_helper/gun.ex +++ b/lib/pleroma/http/adapter_helper/gun.ex @@ -5,11 +5,11 @@ defmodule Pleroma.HTTP.AdapterHelper.Gun do @behaviour Pleroma.HTTP.AdapterHelper - require Logger - alias Pleroma.HTTP.AdapterHelper alias Pleroma.Pool.Connections + require Logger + @defaults [ connect_timeout: 5_000, domain_lookup_timeout: 5_000, diff --git a/lib/pleroma/http/adapter_helper/hackney.ex b/lib/pleroma/http/adapter_helper/hackney.ex index a0e161eaa..d08afae0c 100644 --- a/lib/pleroma/http/adapter_helper/hackney.ex +++ b/lib/pleroma/http/adapter_helper/hackney.ex @@ -13,8 +13,10 @@ defmodule Pleroma.HTTP.AdapterHelper.Hackney do def options(connection_opts \\ [], %URI{} = uri) do proxy = Pleroma.Config.get([:http, :proxy_url], nil) + config_opts = Pleroma.Config.get([:http, :adapter], []) + @defaults - |> Keyword.merge(Pleroma.Config.get([:http, :adapter], [])) + |> Keyword.merge(config_opts) |> Keyword.merge(connection_opts) |> add_scheme_opts(uri) |> Pleroma.HTTP.AdapterHelper.maybe_add_proxy(proxy) diff --git a/lib/pleroma/http/connection.ex b/lib/pleroma/http/connection.ex index 97eec88c1..777e5d4c8 100644 --- a/lib/pleroma/http/connection.ex +++ b/lib/pleroma/http/connection.ex @@ -6,6 +6,14 @@ defmodule Pleroma.HTTP.Connection do @moduledoc """ Configure Tesla.Client with default and customized adapter options. """ + + alias Pleroma.Config + alias Pleroma.HTTP.AdapterHelper + + require Logger + + @defaults [pool: :federation] + @type ip_address :: ipv4_address() | ipv6_address() @type ipv4_address :: {0..255, 0..255, 0..255, 0..255} @type ipv6_address :: @@ -13,13 +21,6 @@ defmodule Pleroma.HTTP.Connection do @type proxy_type() :: :socks4 | :socks5 @type host() :: charlist() | ip_address() - @defaults [pool: :federation] - - require Logger - - alias Pleroma.Config - alias Pleroma.HTTP.AdapterHelper - @doc """ Merge default connection & adapter options with received ones. """ diff --git a/lib/pleroma/pool/connections.ex b/lib/pleroma/pool/connections.ex index f96c08f21..7529e9240 100644 --- a/lib/pleroma/pool/connections.ex +++ b/lib/pleroma/pool/connections.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Pool.Connections do use GenServer alias Pleroma.Config + alias Pleroma.Gun require Logger @@ -19,8 +20,6 @@ defmodule Pleroma.Pool.Connections do defstruct conns: %{}, opts: [] - alias Pleroma.Gun - @spec start_link({atom(), keyword()}) :: {:ok, pid()} def start_link({name, opts}) do GenServer.start_link(__MODULE__, opts, name: name) From 14678a7708fb43e60f2f3b610f15d5090616d85c Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Sat, 7 Mar 2020 10:12:34 +0300 Subject: [PATCH 075/188] using `stub` instead `expect` --- test/http/adapter_helper/gun_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/http/adapter_helper/gun_test.exs b/test/http/adapter_helper/gun_test.exs index b1b34858a..c65b89786 100644 --- a/test/http/adapter_helper/gun_test.exs +++ b/test/http/adapter_helper/gun_test.exs @@ -23,7 +23,7 @@ defp gun_mock(_) do defp gun_mock do Pleroma.GunMock - |> expect(:open, fn _, _, _ -> Task.start_link(fn -> Process.sleep(1000) end) end) + |> stub(:open, fn _, _, _ -> Task.start_link(fn -> Process.sleep(1000) end) end) |> expect(:await_up, fn _, _ -> {:ok, :http} end) |> expect(:set_owner, fn _, _ -> :ok end) end From 9f884a263904c8b243507d35b29da712a31fb444 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Sat, 7 Mar 2020 11:01:37 +0300 Subject: [PATCH 076/188] tests changes --- test/http/adapter_helper/gun_test.exs | 4 +- test/http/connection_test.exs | 28 -------------- test/http_test.exs | 2 +- test/reverse_proxy/reverse_proxy_test.exs | 45 ++++++++++++----------- 4 files changed, 27 insertions(+), 52 deletions(-) diff --git a/test/http/adapter_helper/gun_test.exs b/test/http/adapter_helper/gun_test.exs index c65b89786..66622b605 100644 --- a/test/http/adapter_helper/gun_test.exs +++ b/test/http/adapter_helper/gun_test.exs @@ -24,8 +24,8 @@ defp gun_mock(_) do defp gun_mock do Pleroma.GunMock |> stub(:open, fn _, _, _ -> Task.start_link(fn -> Process.sleep(1000) end) end) - |> expect(:await_up, fn _, _ -> {:ok, :http} end) - |> expect(:set_owner, fn _, _ -> :ok end) + |> stub(:await_up, fn _, _ -> {:ok, :http} end) + |> stub(:set_owner, fn _, _ -> :ok end) end describe "options/1" do diff --git a/test/http/connection_test.exs b/test/http/connection_test.exs index a5ddfd435..25a2bac1c 100644 --- a/test/http/connection_test.exs +++ b/test/http/connection_test.exs @@ -7,13 +7,10 @@ defmodule Pleroma.HTTP.ConnectionTest do use Pleroma.Tests.Helpers import ExUnit.CaptureLog - import Mox alias Pleroma.Config alias Pleroma.HTTP.Connection - setup :verify_on_exit! - describe "parse_host/1" do test "as atom to charlist" do assert Connection.parse_host(:localhost) == 'localhost' @@ -115,30 +112,5 @@ test "passed opts have more weight than defaults" do assert opts[:proxy] == {'example.com', 4321} end - - test "default ssl adapter opts with connection" do - adapter = Application.get_env(:tesla, :adapter) - Application.put_env(:tesla, :adapter, Tesla.Adapter.Gun) - on_exit(fn -> Application.put_env(:tesla, :adapter, adapter) end) - - uri = URI.parse("https://some-domain.com") - - Pleroma.GunMock - |> expect(:open, fn 'some-domain.com', 443, _ -> - Task.start_link(fn -> Process.sleep(1000) end) - end) - |> expect(:await_up, fn _, _ -> {:ok, :http2} end) - |> expect(:set_owner, fn _, _ -> :ok end) - - :ok = Pleroma.Gun.Conn.open(uri, :gun_connections) - - opts = Connection.options(uri) - - assert opts[:certificates_verification] - refute opts[:tls_opts] == [] - - assert opts[:close_conn] == false - assert is_pid(opts[:conn]) - end end end diff --git a/test/http_test.exs b/test/http_test.exs index fd254b590..618485b55 100644 --- a/test/http_test.exs +++ b/test/http_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.HTTPTest do - use ExUnit.Case + use ExUnit.Case, async: true use Pleroma.Tests.Helpers import Tesla.Mock alias Pleroma.HTTP diff --git a/test/reverse_proxy/reverse_proxy_test.exs b/test/reverse_proxy/reverse_proxy_test.exs index c17ab0f89..abdfddcb7 100644 --- a/test/reverse_proxy/reverse_proxy_test.exs +++ b/test/reverse_proxy/reverse_proxy_test.exs @@ -3,14 +3,17 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.ReverseProxyTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true + import ExUnit.CaptureLog import Mox + alias Pleroma.ReverseProxy alias Pleroma.ReverseProxy.ClientMock + alias Plug.Conn setup_all do - {:ok, _} = Registry.start_link(keys: :unique, name: Pleroma.ReverseProxy.ClientMock) + {:ok, _} = Registry.start_link(keys: :unique, name: ClientMock) :ok end @@ -21,7 +24,7 @@ defp user_agent_mock(user_agent, invokes) do ClientMock |> expect(:request, fn :get, url, _, _, _ -> - Registry.register(Pleroma.ReverseProxy.ClientMock, url, 0) + Registry.register(ClientMock, url, 0) {:ok, 200, [ @@ -30,13 +33,13 @@ defp user_agent_mock(user_agent, invokes) do ], %{url: url}} end) |> expect(:stream_body, invokes, fn %{url: url} = client -> - case Registry.lookup(Pleroma.ReverseProxy.ClientMock, url) do + case Registry.lookup(ClientMock, url) do [{_, 0}] -> - Registry.update_value(Pleroma.ReverseProxy.ClientMock, url, &(&1 + 1)) + Registry.update_value(ClientMock, url, &(&1 + 1)) {:ok, json, client} [{_, 1}] -> - Registry.unregister(Pleroma.ReverseProxy.ClientMock, url) + Registry.unregister(ClientMock, url) :done end end) @@ -81,7 +84,7 @@ test "closed connection", %{conn: conn} do defp stream_mock(invokes, with_close? \\ false) do ClientMock |> expect(:request, fn :get, "/stream-bytes/" <> length, _, _, _ -> - Registry.register(Pleroma.ReverseProxy.ClientMock, "/stream-bytes/" <> length, 0) + Registry.register(ClientMock, "/stream-bytes/" <> length, 0) {:ok, 200, [{"content-type", "application/octet-stream"}], %{url: "/stream-bytes/" <> length}} @@ -89,10 +92,10 @@ defp stream_mock(invokes, with_close? \\ false) do |> expect(:stream_body, invokes, fn %{url: "/stream-bytes/" <> length} = client -> max = String.to_integer(length) - case Registry.lookup(Pleroma.ReverseProxy.ClientMock, "/stream-bytes/" <> length) do + case Registry.lookup(ClientMock, "/stream-bytes/" <> length) do [{_, current}] when current < max -> Registry.update_value( - Pleroma.ReverseProxy.ClientMock, + ClientMock, "/stream-bytes/" <> length, &(&1 + 10) ) @@ -100,7 +103,7 @@ defp stream_mock(invokes, with_close? \\ false) do {:ok, "0123456789", client} [{_, ^max}] -> - Registry.unregister(Pleroma.ReverseProxy.ClientMock, "/stream-bytes/" <> length) + Registry.unregister(ClientMock, "/stream-bytes/" <> length) :done end end) @@ -214,24 +217,24 @@ test "streaming", %{conn: conn} do conn = ReverseProxy.call(conn, "/stream-bytes/200") assert conn.state == :chunked assert byte_size(conn.resp_body) == 200 - assert Plug.Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"] + assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"] end defp headers_mock(_) do ClientMock |> expect(:request, fn :get, "/headers", headers, _, _ -> - Registry.register(Pleroma.ReverseProxy.ClientMock, "/headers", 0) + Registry.register(ClientMock, "/headers", 0) {:ok, 200, [{"content-type", "application/json"}], %{url: "/headers", headers: headers}} end) |> expect(:stream_body, 2, fn %{url: url, headers: headers} = client -> - case Registry.lookup(Pleroma.ReverseProxy.ClientMock, url) do + case Registry.lookup(ClientMock, url) do [{_, 0}] -> - Registry.update_value(Pleroma.ReverseProxy.ClientMock, url, &(&1 + 1)) + Registry.update_value(ClientMock, url, &(&1 + 1)) headers = for {k, v} <- headers, into: %{}, do: {String.capitalize(k), v} {:ok, Jason.encode!(%{headers: headers}), client} [{_, 1}] -> - Registry.unregister(Pleroma.ReverseProxy.ClientMock, url) + Registry.unregister(ClientMock, url) :done end end) @@ -244,7 +247,7 @@ defp headers_mock(_) do test "header passes", %{conn: conn} do conn = - Plug.Conn.put_req_header( + Conn.put_req_header( conn, "accept", "text/html" @@ -257,7 +260,7 @@ test "header passes", %{conn: conn} do test "header is filtered", %{conn: conn} do conn = - Plug.Conn.put_req_header( + Conn.put_req_header( conn, "accept-language", "en-US" @@ -301,18 +304,18 @@ test "add cache-control", %{conn: conn} do defp disposition_headers_mock(headers) do ClientMock |> expect(:request, fn :get, "/disposition", _, _, _ -> - Registry.register(Pleroma.ReverseProxy.ClientMock, "/disposition", 0) + Registry.register(ClientMock, "/disposition", 0) {:ok, 200, headers, %{url: "/disposition"}} end) |> expect(:stream_body, 2, fn %{url: "/disposition"} = client -> - case Registry.lookup(Pleroma.ReverseProxy.ClientMock, "/disposition") do + case Registry.lookup(ClientMock, "/disposition") do [{_, 0}] -> - Registry.update_value(Pleroma.ReverseProxy.ClientMock, "/disposition", &(&1 + 1)) + Registry.update_value(ClientMock, "/disposition", &(&1 + 1)) {:ok, "", client} [{_, 1}] -> - Registry.unregister(Pleroma.ReverseProxy.ClientMock, "/disposition") + Registry.unregister(ClientMock, "/disposition") :done end end) From 5f42ecc4c74172b1b17c126106fda9da24065b11 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Sat, 7 Mar 2020 12:24:39 +0300 Subject: [PATCH 077/188] start gun upload pool, if proxy_remote is enabled --- lib/pleroma/pool/supervisor.ex | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/pool/supervisor.ex b/lib/pleroma/pool/supervisor.ex index f436849ac..8dc5b64b7 100644 --- a/lib/pleroma/pool/supervisor.ex +++ b/lib/pleroma/pool/supervisor.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Pool.Supervisor do use Supervisor + alias Pleroma.Config alias Pleroma.Pool def start_link(args) do @@ -17,8 +18,7 @@ def init(_) do %{ id: Pool.Connections, start: - {Pool.Connections, :start_link, - [{:gun_connections, Pleroma.Config.get([:connections_pool])}]} + {Pool.Connections, :start_link, [{:gun_connections, Config.get([:connections_pool])}]} } ] ++ pools() @@ -26,7 +26,16 @@ def init(_) do end defp pools do - for {pool_name, pool_opts} <- Pleroma.Config.get([:pools]) do + pools = Config.get(:pools) + + pools = + if Config.get([Pleroma.Upload, :proxy_remote]) == false do + Keyword.delete(pools, :upload) + else + pools + end + + for {pool_name, pool_opts} <- pools do pool_opts |> Keyword.put(:id, {Pool, pool_name}) |> Keyword.put(:name, pool_name) From 426f5ee48a09dbf321c013db08cc849c8929d86d Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 10 Mar 2020 15:31:44 +0300 Subject: [PATCH 078/188] tesla adapter can't be changed in adminFE --- lib/pleroma/config/transfer_task.ex | 58 +++++++++---------- .../admin_api/admin_api_controller_test.exs | 21 +------ 2 files changed, 31 insertions(+), 48 deletions(-) diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex index bf1b943d8..4a4c022f0 100644 --- a/lib/pleroma/config/transfer_task.ex +++ b/lib/pleroma/config/transfer_task.ex @@ -20,8 +20,7 @@ defmodule Pleroma.Config.TransferTask do {:pleroma, :markup}, {:pleroma, :streamer}, {:pleroma, :pools}, - {:pleroma, :connections_pool}, - {:tesla, :adapter} + {:pleroma, :connections_pool} ] @reboot_time_subkeys [ @@ -35,8 +34,6 @@ defmodule Pleroma.Config.TransferTask do {:pleroma, :gopher, [:enabled]} ] - @reject [nil, :prometheus] - def start_link(_) do load_and_update_env() if Pleroma.Config.get(:env) == :test, do: Ecto.Adapters.SQL.Sandbox.checkin(Repo) @@ -45,35 +42,30 @@ def start_link(_) do @spec load_and_update_env([ConfigDB.t()]) :: :ok | false def load_and_update_env(deleted \\ [], restart_pleroma? \\ true) do - with {:configurable, true} <- - {:configurable, Pleroma.Config.get(:configurable_from_database)}, - true <- Ecto.Adapters.SQL.table_exists?(Repo, "config"), - started_applications <- Application.started_applications() do + with {_, true} <- {:configurable, Pleroma.Config.get(:configurable_from_database)} do # We need to restart applications for loaded settings take effect - in_db = Repo.all(ConfigDB) with_deleted = in_db ++ deleted - reject_for_restart = if restart_pleroma?, do: @reject, else: [:pleroma | @reject] + # TODO: some problem with prometheus after restart! + reject = [nil, :prometheus] - applications = - with_deleted - |> Enum.map(&merge_and_update(&1)) - |> Enum.uniq() - # TODO: some problem with prometheus after restart! - |> Enum.reject(&(&1 in reject_for_restart)) - - # to be ensured that pleroma will be restarted last - applications = - if :pleroma in applications do - List.delete(applications, :pleroma) ++ [:pleroma] + reject_for_restart = + if restart_pleroma? do + reject else - Restarter.Pleroma.rebooted() - applications + [:pleroma | reject] end - Enum.each(applications, &restart(started_applications, &1, Pleroma.Config.get(:env))) + started_applications = Application.started_applications() + + with_deleted + |> Enum.map(&merge_and_update(&1)) + |> Enum.uniq() + |> Enum.reject(&(&1 in reject_for_restart)) + |> maybe_set_pleroma_last() + |> Enum.each(&restart(started_applications, &1, Pleroma.Config.get(:env))) :ok else @@ -81,6 +73,18 @@ def load_and_update_env(deleted \\ [], restart_pleroma? \\ true) do end end + defp maybe_set_pleroma_last(apps) do + # to be ensured that pleroma will be restarted last + if :pleroma in apps do + apps + |> List.delete(:pleroma) + |> List.insert_at(-1, :pleroma) + else + Restarter.Pleroma.rebooted() + apps + end + end + defp group_for_restart(:logger, key, _, merged_value) do # change logger configuration in runtime, without restart if Keyword.keyword?(merged_value) and @@ -93,14 +97,10 @@ defp group_for_restart(:logger, key, _, merged_value) do nil end - defp group_for_restart(:tesla, _, _, _), do: :pleroma - defp group_for_restart(group, _, _, _) when group != :pleroma, do: group defp group_for_restart(group, key, value, _) do - if pleroma_need_restart?(group, key, value) do - group - end + if pleroma_need_restart?(group, key, value), do: group end defp merge_and_update(setting) do diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index d6b839948..76240e5bc 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -2513,8 +2513,7 @@ test "saving full setting if value is not keyword", %{conn: conn} do "value" => "Tesla.Adapter.Httpc", "db" => [":adapter"] } - ], - "need_reboot" => true + ] } end @@ -2586,9 +2585,6 @@ test "update config setting & delete with fallback to default value", %{ end test "common config example", %{conn: conn} do - adapter = Application.get_env(:tesla, :adapter) - on_exit(fn -> Application.put_env(:tesla, :adapter, adapter) end) - conn = post(conn, "/api/pleroma/admin/config", %{ configs: [ @@ -2607,16 +2603,10 @@ test "common config example", %{conn: conn} do %{"tuple" => [":regex4", "~r/https:\/\/example.com/s"]}, %{"tuple" => [":name", "Pleroma"]} ] - }, - %{ - "group" => ":tesla", - "key" => ":adapter", - "value" => "Tesla.Adapter.Httpc" } ] }) - assert Application.get_env(:tesla, :adapter) == Tesla.Adapter.Httpc assert Config.get([Pleroma.Captcha.NotReal, :name]) == "Pleroma" assert json_response(conn, 200) == %{ @@ -2648,15 +2638,8 @@ test "common config example", %{conn: conn} do ":regex4", ":name" ] - }, - %{ - "group" => ":tesla", - "key" => ":adapter", - "value" => "Tesla.Adapter.Httpc", - "db" => [":adapter"] } - ], - "need_reboot" => true + ] } end From f39e1b9eff859c0795911212c59304f68fca92bc Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 10 Mar 2020 15:54:11 +0300 Subject: [PATCH 079/188] add verify tls_opts only when we open connection for other requests tesla will add tls_opts --- lib/pleroma/gun/conn.ex | 24 +++++++++++++++++ lib/pleroma/http/adapter_helper/gun.ex | 33 ++++------------------- lib/pleroma/http/connection.ex | 13 +++++++++ test/http/adapter_helper/gun_test.exs | 37 ++++---------------------- test/http/connection_test.exs | 19 +++++++++++++ 5 files changed, 66 insertions(+), 60 deletions(-) diff --git a/lib/pleroma/gun/conn.ex b/lib/pleroma/gun/conn.ex index 319718690..57a847c30 100644 --- a/lib/pleroma/gun/conn.ex +++ b/lib/pleroma/gun/conn.ex @@ -45,6 +45,7 @@ def open(%URI{} = uri, name, opts) do |> Map.put_new(:retry, pool_opts[:retry] || 1) |> Map.put_new(:retry_timeout, pool_opts[:retry_timeout] || 1000) |> Map.put_new(:await_up_timeout, pool_opts[:await_up_timeout] || 5_000) + |> maybe_add_tls_opts(uri) key = "#{uri.scheme}:#{uri.host}:#{uri.port}" @@ -70,6 +71,29 @@ def open(%URI{} = uri, name, opts) do end end + defp maybe_add_tls_opts(opts, %URI{scheme: "http"}), do: opts + + defp maybe_add_tls_opts(opts, %URI{scheme: "https", host: host}) do + tls_opts = [ + verify: :verify_peer, + cacertfile: CAStore.file_path(), + depth: 20, + reuse_sessions: false, + verify_fun: + {&:ssl_verify_hostname.verify_fun/3, + [check_hostname: Pleroma.HTTP.Connection.format_host(host)]} + ] + + tls_opts = + if Keyword.keyword?(opts[:tls_opts]) do + Keyword.merge(tls_opts, opts[:tls_opts]) + else + tls_opts + end + + Map.put(opts, :tls_opts, tls_opts) + end + defp do_open(uri, %{proxy: {proxy_host, proxy_port}} = opts) do connect_opts = uri diff --git a/lib/pleroma/http/adapter_helper/gun.ex b/lib/pleroma/http/adapter_helper/gun.ex index 862e851c0..55c2b192a 100644 --- a/lib/pleroma/http/adapter_helper/gun.ex +++ b/lib/pleroma/http/adapter_helper/gun.ex @@ -45,21 +45,11 @@ def after_request(opts) do defp add_scheme_opts(opts, %URI{scheme: "http"}), do: opts - defp add_scheme_opts(opts, %URI{scheme: "https", host: host}) do - adapter_opts = [ - certificates_verification: true, - transport: :tls, - tls_opts: [ - verify: :verify_peer, - cacertfile: CAStore.file_path(), - depth: 20, - reuse_sessions: false, - verify_fun: {&:ssl_verify_hostname.verify_fun/3, [check_hostname: format_host(host)]}, - log_level: :warning - ] - ] - - Keyword.merge(opts, adapter_opts) + defp add_scheme_opts(opts, %URI{scheme: "https"}) do + opts + |> Keyword.put(:certificates_verification, true) + |> Keyword.put(:transport, :tls) + |> Keyword.put(:tls_opts, log_level: :warning) end defp maybe_get_conn(adapter_opts, uri, connection_opts) do @@ -93,17 +83,4 @@ defp try_to_get_conn(uri, opts) do |> Keyword.put(:close_conn, false) end end - - @spec format_host(String.t()) :: charlist() - def format_host(host) do - host_charlist = to_charlist(host) - - case :inet.parse_address(host_charlist) do - {:error, :einval} -> - :idna.encode(host_charlist) - - {:ok, _ip} -> - host_charlist - end - end end diff --git a/lib/pleroma/http/connection.ex b/lib/pleroma/http/connection.ex index 777e5d4c8..0fc88f708 100644 --- a/lib/pleroma/http/connection.ex +++ b/lib/pleroma/http/connection.ex @@ -106,4 +106,17 @@ def parse_host(host) when is_binary(host) do {:ok, ip} -> ip end end + + @spec format_host(String.t()) :: charlist() + def format_host(host) do + host_charlist = to_charlist(host) + + case :inet.parse_address(host_charlist) do + {:error, :einval} -> + :idna.encode(host_charlist) + + {:ok, _ip} -> + host_charlist + end + end end diff --git a/test/http/adapter_helper/gun_test.exs b/test/http/adapter_helper/gun_test.exs index 66622b605..6af8be15d 100644 --- a/test/http/adapter_helper/gun_test.exs +++ b/test/http/adapter_helper/gun_test.exs @@ -38,31 +38,23 @@ test "https url with default port" do opts = Gun.options([receive_conn: false], uri) assert opts[:certificates_verification] - refute opts[:tls_opts] == [] - - assert opts[:tls_opts][:verify_fun] == - {&:ssl_verify_hostname.verify_fun/3, [check_hostname: 'example.com']} - - assert File.exists?(opts[:tls_opts][:cacertfile]) + assert opts[:tls_opts][:log_level] == :warning end test "https ipv4 with default port" do uri = URI.parse("https://127.0.0.1") opts = Gun.options([receive_conn: false], uri) - - assert opts[:tls_opts][:verify_fun] == - {&:ssl_verify_hostname.verify_fun/3, [check_hostname: '127.0.0.1']} + assert opts[:certificates_verification] + assert opts[:tls_opts][:log_level] == :warning end test "https ipv6 with default port" do uri = URI.parse("https://[2a03:2880:f10c:83:face:b00c:0:25de]") opts = Gun.options([receive_conn: false], uri) - - assert opts[:tls_opts][:verify_fun] == - {&:ssl_verify_hostname.verify_fun/3, - [check_hostname: '2a03:2880:f10c:83:face:b00c:0:25de']} + assert opts[:certificates_verification] + assert opts[:tls_opts][:log_level] == :warning end test "https url with non standart port" do @@ -269,23 +261,4 @@ test "with ipv6" do } = Connections.get_state(:gun_connections) end end - - describe "format_host/1" do - test "with domain" do - assert Gun.format_host("example.com") == 'example.com' - end - - test "with idna domain" do - assert Gun.format_host("ですexample.com") == 'xn--example-183fne.com' - end - - test "with ipv4" do - assert Gun.format_host("127.0.0.1") == '127.0.0.1' - end - - test "with ipv6" do - assert Gun.format_host("2a03:2880:f10c:83:face:b00c:0:25de") == - '2a03:2880:f10c:83:face:b00c:0:25de' - end - end end diff --git a/test/http/connection_test.exs b/test/http/connection_test.exs index 25a2bac1c..0f62eddd2 100644 --- a/test/http/connection_test.exs +++ b/test/http/connection_test.exs @@ -113,4 +113,23 @@ test "passed opts have more weight than defaults" do assert opts[:proxy] == {'example.com', 4321} end end + + describe "format_host/1" do + test "with domain" do + assert Connection.format_host("example.com") == 'example.com' + end + + test "with idna domain" do + assert Connection.format_host("ですexample.com") == 'xn--example-183fne.com' + end + + test "with ipv4" do + assert Connection.format_host("127.0.0.1") == '127.0.0.1' + end + + test "with ipv6" do + assert Connection.format_host("2a03:2880:f10c:83:face:b00c:0:25de") == + '2a03:2880:f10c:83:face:b00c:0:25de' + end + end end From 863ec33ba2a90708d199f18683ffe0c4658c710a Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 11 Mar 2020 12:21:44 +0100 Subject: [PATCH 080/188] Add support for funkwhale Audio activity reel2bits fixture not included as it lacks the Actor fixture for it. Closes: https://git.pleroma.social/pleroma/pleroma/issues/1624 Closes: https://git.pleroma.social/pleroma/pleroma/issues/764 --- .../web/activity_pub/transmogrifier.ex | 5 ++- .../web/mastodon_api/views/status_view.ex | 2 +- test/fixtures/tesla_mock/funkwhale_audio.json | 44 +++++++++++++++++++ .../tesla_mock/funkwhale_channel.json | 44 +++++++++++++++++++ test/support/http_request_mock.ex | 15 +++++++ .../mastodon_api/views/status_view_test.exs | 16 +++++++ test/web/oauth/oauth_controller_test.exs | 2 +- 7 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/tesla_mock/funkwhale_audio.json create mode 100644 test/fixtures/tesla_mock/funkwhale_channel.json diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 9cd3de705..f52b065f6 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -229,7 +229,8 @@ def fix_url(%{"url" => url} = object) when is_map(url) do Map.put(object, "url", url["href"]) end - def fix_url(%{"type" => "Video", "url" => url} = object) when is_list(url) do + def fix_url(%{"type" => object_type, "url" => url} = object) + when object_type in ["Video", "Audio"] and is_list(url) do first_element = Enum.at(url, 0) link_element = Enum.find(url, fn x -> is_map(x) and x["mimeType"] == "text/html" end) @@ -398,7 +399,7 @@ def handle_incoming( %{"type" => "Create", "object" => %{"type" => objtype} = object} = data, options ) - when objtype in ["Article", "Event", "Note", "Video", "Page", "Question", "Answer"] do + when objtype in ["Article", "Event", "Note", "Video", "Page", "Question", "Answer", "Audio"] do actor = Containment.get_actor(data) data = diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index f7469cdff..a042075f5 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -421,7 +421,7 @@ def get_reply_to(%{data: %{"object" => _object}} = activity, _) do end def render_content(%{data: %{"type" => object_type}} = object) - when object_type in ["Video", "Event"] do + when object_type in ["Video", "Event", "Audio"] do with name when not is_nil(name) and name != "" <- object.data["name"] do "

#{name}

#{object.data["content"]}" else diff --git a/test/fixtures/tesla_mock/funkwhale_audio.json b/test/fixtures/tesla_mock/funkwhale_audio.json new file mode 100644 index 000000000..15736b1f8 --- /dev/null +++ b/test/fixtures/tesla_mock/funkwhale_audio.json @@ -0,0 +1,44 @@ +{ + "id": "https://channels.tests.funkwhale.audio/federation/music/uploads/42342395-0208-4fee-a38d-259a6dae0871", + "type": "Audio", + "name": "Compositions - Test Audio for Pleroma", + "attributedTo": "https://channels.tests.funkwhale.audio/federation/actors/compositions", + "published": "2020-03-11T10:01:52.714918+00:00", + "to": "https://www.w3.org/ns/activitystreams#Public", + "url": [ + { + "type": "Link", + "mimeType": "audio/ogg", + "href": "https://channels.tests.funkwhale.audio/api/v1/listen/3901e5d8-0445-49d5-9711-e096cf32e515/?upload=42342395-0208-4fee-a38d-259a6dae0871&download=false" + }, + { + "type": "Link", + "mimeType": "text/html", + "href": "https://channels.tests.funkwhale.audio/library/tracks/74" + } + ], + "content": "

This is a test Audio for Pleroma.

", + "mediaType": "text/html", + "tag": [ + { + "type": "Hashtag", + "name": "#funkwhale" + }, + { + "type": "Hashtag", + "name": "#test" + }, + { + "type": "Hashtag", + "name": "#tests" + } + ], + "summary": "#funkwhale #test #tests", + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers" + } + ] +} diff --git a/test/fixtures/tesla_mock/funkwhale_channel.json b/test/fixtures/tesla_mock/funkwhale_channel.json new file mode 100644 index 000000000..cf9ee8151 --- /dev/null +++ b/test/fixtures/tesla_mock/funkwhale_channel.json @@ -0,0 +1,44 @@ +{ + "id": "https://channels.tests.funkwhale.audio/federation/actors/compositions", + "outbox": "https://channels.tests.funkwhale.audio/federation/actors/compositions/outbox", + "inbox": "https://channels.tests.funkwhale.audio/federation/actors/compositions/inbox", + "preferredUsername": "compositions", + "type": "Person", + "name": "Compositions", + "followers": "https://channels.tests.funkwhale.audio/federation/actors/compositions/followers", + "following": "https://channels.tests.funkwhale.audio/federation/actors/compositions/following", + "manuallyApprovesFollowers": false, + "url": [ + { + "type": "Link", + "href": "https://channels.tests.funkwhale.audio/channels/compositions", + "mediaType": "text/html" + }, + { + "type": "Link", + "href": "https://channels.tests.funkwhale.audio/api/v1/channels/compositions/rss", + "mediaType": "application/rss+xml" + } + ], + "icon": { + "type": "Image", + "url": "https://channels.tests.funkwhale.audio/media/attachments/75/b4/f1/nosmile.jpeg", + "mediaType": "image/jpeg" + }, + "summary": "

I'm testing federation with the fediverse :)

", + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers" + } + ], + "publicKey": { + "owner": "https://channels.tests.funkwhale.audio/federation/actors/compositions", + "publicKeyPem": "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAv25u57oZfVLV3KltS+HcsdSx9Op4MmzIes1J8Wu8s0KbdXf2zEwS\nsVqyHgs/XCbnzsR3FqyJTo46D2BVnvZcuU5srNcR2I2HMaqQ0oVdnATE4K6KdcgV\nN+98pMWo56B8LTgE1VpvqbsrXLi9jCTzjrkebVMOP+ZVu+64v1qdgddseblYMnBZ\nct0s7ONbHnqrWlTGf5wES1uIZTVdn5r4MduZG+Uenfi1opBS0lUUxfWdW9r0oF2b\nyneZUyaUCbEroeKbqsweXCWVgnMarUOsgqC42KM4cf95lySSwTSaUtZYIbTw7s9W\n2jveU/rVg8BYZu5JK5obgBoxtlUeUoSswwIDAQAB\n-----END RSA PUBLIC KEY-----\n", + "id": "https://channels.tests.funkwhale.audio/federation/actors/compositions#main-key" + }, + "endpoints": { + "sharedInbox": "https://channels.tests.funkwhale.audio/federation/shared/inbox" + } +} diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index d46887865..0079d8c44 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -1273,6 +1273,21 @@ def get("https://patch.cx/users/rin", _, _, _) do {:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/rin.json")}} end + def get( + "https://channels.tests.funkwhale.audio/federation/music/uploads/42342395-0208-4fee-a38d-259a6dae0871", + _, + _, + _ + ) do + {:ok, + %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/funkwhale_audio.json")}} + end + + def get("https://channels.tests.funkwhale.audio/federation/actors/compositions", _, _, _) do + {:ok, + %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/funkwhale_channel.json")}} + end + def get("http://example.com/rel_me/error", _, _, _) do {:ok, %Tesla.Env{status: 404, body: ""}} end diff --git a/test/web/mastodon_api/views/status_view_test.exs b/test/web/mastodon_api/views/status_view_test.exs index 191895c6f..3e1812a1f 100644 --- a/test/web/mastodon_api/views/status_view_test.exs +++ b/test/web/mastodon_api/views/status_view_test.exs @@ -420,6 +420,22 @@ test "a peertube video" do assert length(represented[:media_attachments]) == 1 end + test "funkwhale audio" do + user = insert(:user) + + {:ok, object} = + Pleroma.Object.Fetcher.fetch_object_from_id( + "https://channels.tests.funkwhale.audio/federation/music/uploads/42342395-0208-4fee-a38d-259a6dae0871" + ) + + %Activity{} = activity = Activity.get_create_by_object_ap_id(object.data["id"]) + + represented = StatusView.render("show.json", %{for: user, activity: activity}) + + assert represented[:id] == to_string(activity.id) + assert length(represented[:media_attachments]) == 1 + end + test "a Mobilizon event" do user = insert(:user) diff --git a/test/web/oauth/oauth_controller_test.exs b/test/web/oauth/oauth_controller_test.exs index cff469c28..5f86d999c 100644 --- a/test/web/oauth/oauth_controller_test.exs +++ b/test/web/oauth/oauth_controller_test.exs @@ -581,7 +581,7 @@ test "redirects with oauth authorization, " <> # In case scope param is missing, expecting _all_ app-supported scopes to be granted for user <- [non_admin, admin], {requested_scopes, expected_scopes} <- - %{scopes_subset => scopes_subset, nil => app_scopes} do + %{scopes_subset => scopes_subset, nil: app_scopes} do conn = post( build_conn(), From 1306b92997dc6e76e5d617d529dbc229d5aee200 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 12 Mar 2020 18:28:54 +0300 Subject: [PATCH 081/188] clean up --- lib/pleroma/application.ex | 18 +++-- lib/pleroma/config/transfer_task.ex | 42 +++++------ lib/pleroma/gun/conn.ex | 31 ++++----- lib/pleroma/http/adapter_helper.ex | 2 +- lib/pleroma/http/adapter_helper/gun.ex | 33 ++++----- lib/pleroma/http/connection.ex | 8 +-- lib/pleroma/http/http.ex | 5 +- lib/pleroma/pool/connections.ex | 96 +++++++++----------------- test/http/adapter_helper/gun_test.exs | 12 ++-- test/pool/connections_test.exs | 2 +- 10 files changed, 94 insertions(+), 155 deletions(-) diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index c8a0617a5..55b5be488 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -42,7 +42,9 @@ def start(_type, _args) do setup_instrumenters() load_custom_modules() - if adapter() == Tesla.Adapter.Gun do + adapter = Application.get_env(:tesla, :adapter) + + if adapter == Tesla.Adapter.Gun do if version = Pleroma.OTPVersion.version() do [major, minor] = version @@ -74,7 +76,7 @@ def start(_type, _args) do Pleroma.Plugs.RateLimiter.Supervisor ] ++ cachex_children() ++ - http_pools_children(Config.get(:env)) ++ + http_children(adapter, @env) ++ [ Pleroma.Stats, Pleroma.JobQueueMonitor, @@ -206,15 +208,13 @@ defp task_children(_) do end # start hackney and gun pools in tests - defp http_pools_children(:test) do + defp http_children(_, :test) do hackney_options = Config.get([:hackney_pools, :federation]) hackney_pool = :hackney_pool.child_spec(:federation, hackney_options) [hackney_pool, Pleroma.Pool.Supervisor] end - defp http_pools_children(_), do: http_pools(adapter()) - - defp http_pools(Tesla.Adapter.Hackney) do + defp http_children(Tesla.Adapter.Hackney, _) do pools = [:federation, :media] pools = @@ -230,9 +230,7 @@ defp http_pools(Tesla.Adapter.Hackney) do end end - defp http_pools(Tesla.Adapter.Gun), do: [Pleroma.Pool.Supervisor] + defp http_children(Tesla.Adapter.Gun, _), do: [Pleroma.Pool.Supervisor] - defp http_pools(_), do: [] - - defp adapter, do: Application.get_env(:tesla, :adapter) + defp http_children(_, _), do: [] end diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex index 4a4c022f0..b6d80adb7 100644 --- a/lib/pleroma/config/transfer_task.ex +++ b/lib/pleroma/config/transfer_task.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Config.TransferTask do use Task + alias Pleroma.Config alias Pleroma.ConfigDB alias Pleroma.Repo @@ -36,36 +37,31 @@ defmodule Pleroma.Config.TransferTask do def start_link(_) do load_and_update_env() - if Pleroma.Config.get(:env) == :test, do: Ecto.Adapters.SQL.Sandbox.checkin(Repo) + if Config.get(:env) == :test, do: Ecto.Adapters.SQL.Sandbox.checkin(Repo) :ignore end - @spec load_and_update_env([ConfigDB.t()]) :: :ok | false - def load_and_update_env(deleted \\ [], restart_pleroma? \\ true) do - with {_, true} <- {:configurable, Pleroma.Config.get(:configurable_from_database)} do + @spec load_and_update_env([ConfigDB.t()], boolean()) :: :ok + def load_and_update_env(deleted_settings \\ [], restart_pleroma? \\ true) do + with {_, true} <- {:configurable, Config.get(:configurable_from_database)} do # We need to restart applications for loaded settings take effect - in_db = Repo.all(ConfigDB) - - with_deleted = in_db ++ deleted # TODO: some problem with prometheus after restart! - reject = [nil, :prometheus] - - reject_for_restart = + reject_restart = if restart_pleroma? do - reject + [nil, :prometheus] else - [:pleroma | reject] + [:pleroma, nil, :prometheus] end started_applications = Application.started_applications() - with_deleted - |> Enum.map(&merge_and_update(&1)) + (Repo.all(ConfigDB) ++ deleted_settings) + |> Enum.map(&merge_and_update/1) |> Enum.uniq() - |> Enum.reject(&(&1 in reject_for_restart)) + |> Enum.reject(&(&1 in reject_restart)) |> maybe_set_pleroma_last() - |> Enum.each(&restart(started_applications, &1, Pleroma.Config.get(:env))) + |> Enum.each(&restart(started_applications, &1, Config.get(:env))) :ok else @@ -108,18 +104,14 @@ defp merge_and_update(setting) do key = ConfigDB.from_string(setting.key) group = ConfigDB.from_string(setting.group) - default = Pleroma.Config.Holder.config(group, key) + default = Config.Holder.config(group, key) value = ConfigDB.from_binary(setting.value) merged_value = - if Ecto.get_meta(setting, :state) == :deleted do - default - else - if can_be_merged?(default, value) do - ConfigDB.merge_group(group, key, default, value) - else - value - end + cond do + Ecto.get_meta(setting, :state) == :deleted -> default + can_be_merged?(default, value) -> ConfigDB.merge_group(group, key, default, value) + true -> value end :ok = update_env(group, key, merged_value) diff --git a/lib/pleroma/gun/conn.ex b/lib/pleroma/gun/conn.ex index 57a847c30..20823a765 100644 --- a/lib/pleroma/gun/conn.ex +++ b/lib/pleroma/gun/conn.ex @@ -49,8 +49,6 @@ def open(%URI{} = uri, name, opts) do key = "#{uri.scheme}:#{uri.host}:#{uri.port}" - Logger.debug("opening new connection #{Connections.compose_uri_log(uri)}") - conn_pid = if Connections.count(name) < opts[:max_connection] do do_open(uri, opts) @@ -109,9 +107,9 @@ defp do_open(uri, %{proxy: {proxy_host, proxy_port}} = opts) do else error -> Logger.warn( - "Received error on opening connection with http proxy #{ - Connections.compose_uri_log(uri) - } #{inspect(error)}" + "Opening proxied connection to #{compose_uri_log(uri)} failed with error #{ + inspect(error) + }" ) error @@ -145,9 +143,9 @@ defp do_open(uri, %{proxy: {proxy_type, proxy_host, proxy_port}} = opts) do else error -> Logger.warn( - "Received error on opening connection with socks proxy #{ - Connections.compose_uri_log(uri) - } #{inspect(error)}" + "Opening socks proxied connection to #{compose_uri_log(uri)} failed with error #{ + inspect(error) + }" ) error @@ -163,9 +161,7 @@ defp do_open(%URI{host: host, port: port} = uri, opts) do else error -> Logger.warn( - "Received error on opening connection #{Connections.compose_uri_log(uri)} #{ - inspect(error) - }" + "Opening connection to #{compose_uri_log(uri)} failed with error #{inspect(error)}" ) error @@ -184,16 +180,17 @@ defp add_http2_opts(opts, "https", tls_opts) do defp add_http2_opts(opts, _, _), do: opts defp close_least_used_and_do_open(name, uri, opts) do - Logger.debug("try to open conn #{Connections.compose_uri_log(uri)}") - - with [{close_key, least_used} | _conns] <- - Connections.get_unused_conns(name), - :ok <- Gun.close(least_used.conn) do - Connections.remove_conn(name, close_key) + with [{key, conn} | _conns] <- Connections.get_unused_conns(name), + :ok <- Gun.close(conn.conn) do + Connections.remove_conn(name, key) do_open(uri, opts) else [] -> {:error, :pool_overflowed} end end + + def compose_uri_log(%URI{scheme: scheme, host: host, path: path}) do + "#{scheme}://#{host}#{path}" + end end diff --git a/lib/pleroma/http/adapter_helper.ex b/lib/pleroma/http/adapter_helper.ex index 2c13666ec..510722ff9 100644 --- a/lib/pleroma/http/adapter_helper.ex +++ b/lib/pleroma/http/adapter_helper.ex @@ -7,7 +7,7 @@ defmodule Pleroma.HTTP.AdapterHelper do @type proxy :: {Connection.host(), pos_integer()} - | {Connection.proxy_type(), pos_integer()} + | {Connection.proxy_type(), Connection.host(), pos_integer()} @callback options(keyword(), URI.t()) :: keyword() @callback after_request(keyword()) :: :ok diff --git a/lib/pleroma/http/adapter_helper/gun.ex b/lib/pleroma/http/adapter_helper/gun.ex index 55c2b192a..f14b95c19 100644 --- a/lib/pleroma/http/adapter_helper/gun.ex +++ b/lib/pleroma/http/adapter_helper/gun.ex @@ -20,8 +20,8 @@ defmodule Pleroma.HTTP.AdapterHelper.Gun do ] @spec options(keyword(), URI.t()) :: keyword() - def options(connection_opts \\ [], %URI{} = uri) do - formatted_proxy = + def options(incoming_opts \\ [], %URI{} = uri) do + proxy = Pleroma.Config.get([:http, :proxy_url], nil) |> AdapterHelper.format_proxy() @@ -30,8 +30,8 @@ def options(connection_opts \\ [], %URI{} = uri) do @defaults |> Keyword.merge(config_opts) |> add_scheme_opts(uri) - |> AdapterHelper.maybe_add_proxy(formatted_proxy) - |> maybe_get_conn(uri, connection_opts) + |> AdapterHelper.maybe_add_proxy(proxy) + |> maybe_get_conn(uri, incoming_opts) end @spec after_request(keyword()) :: :ok @@ -43,44 +43,35 @@ def after_request(opts) do :ok end - defp add_scheme_opts(opts, %URI{scheme: "http"}), do: opts + defp add_scheme_opts(opts, %{scheme: "http"}), do: opts - defp add_scheme_opts(opts, %URI{scheme: "https"}) do + defp add_scheme_opts(opts, %{scheme: "https"}) do opts |> Keyword.put(:certificates_verification, true) - |> Keyword.put(:transport, :tls) |> Keyword.put(:tls_opts, log_level: :warning) end - defp maybe_get_conn(adapter_opts, uri, connection_opts) do + defp maybe_get_conn(adapter_opts, uri, incoming_opts) do {receive_conn?, opts} = adapter_opts - |> Keyword.merge(connection_opts) + |> Keyword.merge(incoming_opts) |> Keyword.pop(:receive_conn, true) if Connections.alive?(:gun_connections) and receive_conn? do - try_to_get_conn(uri, opts) + checkin_conn(uri, opts) else opts end end - defp try_to_get_conn(uri, opts) do + defp checkin_conn(uri, opts) do case Connections.checkin(uri, :gun_connections) do nil -> - Logger.debug( - "Gun connections pool checkin was not successful. Trying to open conn for next request." - ) - - Task.start(fn -> Pleroma.Gun.Conn.open(uri, :gun_connections, opts) end) + Task.start(Pleroma.Gun.Conn, :open, [uri, :gun_connections, opts]) opts conn when is_pid(conn) -> - Logger.debug("received conn #{inspect(conn)} #{Connections.compose_uri_log(uri)}") - - opts - |> Keyword.put(:conn, conn) - |> Keyword.put(:close_conn, false) + Keyword.merge(opts, conn: conn, close_conn: false) end end end diff --git a/lib/pleroma/http/connection.ex b/lib/pleroma/http/connection.ex index 0fc88f708..76de3fcfe 100644 --- a/lib/pleroma/http/connection.ex +++ b/lib/pleroma/http/connection.ex @@ -71,15 +71,15 @@ def parse_proxy(proxy) when is_binary(proxy) do {:ok, parse_host(host), port} else {_, _} -> - Logger.warn("parsing port in proxy fail #{inspect(proxy)}") + Logger.warn("Parsing port failed #{inspect(proxy)}") {:error, :invalid_proxy_port} :error -> - Logger.warn("parsing port in proxy fail #{inspect(proxy)}") + Logger.warn("Parsing port failed #{inspect(proxy)}") {:error, :invalid_proxy_port} _ -> - Logger.warn("parsing proxy fail #{inspect(proxy)}") + Logger.warn("Parsing proxy failed #{inspect(proxy)}") {:error, :invalid_proxy} end end @@ -89,7 +89,7 @@ def parse_proxy(proxy) when is_tuple(proxy) do {:ok, type, parse_host(host), port} else _ -> - Logger.warn("parsing proxy fail #{inspect(proxy)}") + Logger.warn("Parsing proxy failed #{inspect(proxy)}") {:error, :invalid_proxy} end end diff --git a/lib/pleroma/http/http.ex b/lib/pleroma/http/http.ex index 466a94adc..583b56484 100644 --- a/lib/pleroma/http/http.ex +++ b/lib/pleroma/http/http.ex @@ -56,10 +56,9 @@ def post(url, body, headers \\ [], options \\ []), {:ok, Env.t()} | {:error, any()} def request(method, url, body, headers, options) when is_binary(url) do uri = URI.parse(url) - received_adapter_opts = Keyword.get(options, :adapter, []) - adapter_opts = Connection.options(uri, received_adapter_opts) + adapter_opts = Connection.options(uri, options[:adapter] || []) options = put_in(options[:adapter], adapter_opts) - params = Keyword.get(options, :params, []) + params = options[:params] || [] request = build_request(method, headers, options, url, body, params) adapter = Application.get_env(:tesla, :adapter) diff --git a/lib/pleroma/pool/connections.ex b/lib/pleroma/pool/connections.ex index 7529e9240..772833509 100644 --- a/lib/pleroma/pool/connections.ex +++ b/lib/pleroma/pool/connections.ex @@ -87,18 +87,11 @@ def handle_cast({:add_conn, key, conn}, state) do @impl true def handle_cast({:checkout, conn_pid, pid}, state) do - Logger.debug("checkout #{inspect(conn_pid)}") - state = with true <- Process.alive?(conn_pid), {key, conn} <- find_conn(state.conns, conn_pid), used_by <- List.keydelete(conn.used_by, pid, 0) do - conn_state = - if used_by == [] do - :idle - else - conn.conn_state - end + conn_state = if used_by == [], do: :idle, else: conn.conn_state put_in(state.conns[key], %{conn | conn_state: conn_state, used_by: used_by}) else @@ -123,26 +116,23 @@ def handle_cast({:remove_conn, key}, state) do @impl true def handle_call({:checkin, uri}, from, state) do key = "#{uri.scheme}:#{uri.host}:#{uri.port}" - Logger.debug("checkin #{key}") case state.conns[key] do - %{conn: conn, gun_state: :up} = current_conn -> - Logger.debug("reusing conn #{key}") - + %{conn: pid, gun_state: :up} = conn -> time = :os.system_time(:second) - last_reference = time - current_conn.last_reference - current_crf = crf(last_reference, 100, current_conn.crf) + last_reference = time - conn.last_reference + crf = crf(last_reference, 100, conn.crf) state = put_in(state.conns[key], %{ - current_conn + conn | last_reference: time, - crf: current_crf, + crf: crf, conn_state: :active, - used_by: [from | current_conn.used_by] + used_by: [from | conn.used_by] }) - {:reply, conn, state} + {:reply, pid, state} %{gun_state: :down} -> {:reply, nil, state} @@ -164,50 +154,48 @@ def handle_call(:count, _from, state) do def handle_call(:unused_conns, _from, state) do unused_conns = state.conns - |> Enum.filter(fn {_k, v} -> - v.conn_state == :idle and v.used_by == [] - end) - |> Enum.sort(fn {_x_k, x}, {_y_k, y} -> - x.crf <= y.crf and x.last_reference <= y.last_reference - end) + |> Enum.filter(&filter_conns/1) + |> Enum.sort(&sort_conns/2) {:reply, unused_conns, state} end + defp filter_conns({_, %{conn_state: :idle, used_by: []}}), do: true + defp filter_conns(_), do: false + + defp sort_conns({_, c1}, {_, c2}) do + c1.crf <= c2.crf and c1.last_reference <= c2.last_reference + end + @impl true def handle_info({:gun_up, conn_pid, _protocol}, state) do - state = - with conn_key when is_binary(conn_key) <- compose_key_gun_info(conn_pid), - {key, conn} <- find_conn(state.conns, conn_pid, conn_key), - {true, key} <- {Process.alive?(conn_pid), key} do - time = :os.system_time(:second) - last_reference = time - conn.last_reference - current_crf = crf(last_reference, 100, conn.crf) + %{origin_host: host, origin_scheme: scheme, origin_port: port} = Gun.info(conn_pid) + host = + case :inet.ntoa(host) do + {:error, :einval} -> host + ip -> ip + end + + key = "#{scheme}:#{host}:#{port}" + + state = + with {_key, conn} <- find_conn(state.conns, conn_pid, key), + {true, key} <- {Process.alive?(conn_pid), key} do put_in(state.conns[key], %{ conn | gun_state: :up, - last_reference: time, - crf: current_crf, conn_state: :active, retries: 0 }) else - :error_gun_info -> - Logger.debug(":gun.info caused error") - state - {false, key} -> - Logger.debug(":gun_up message for closed conn #{inspect(conn_pid)}") - put_in( state.conns, Map.delete(state.conns, key) ) nil -> - Logger.debug(":gun_up message for conn which is not found in state") - :ok = Gun.close(conn_pid) state @@ -224,7 +212,6 @@ def handle_info({:gun_down, conn_pid, _protocol, _reason, _killed}, state) do with {key, conn} <- find_conn(state.conns, conn_pid), {true, key} <- {Process.alive?(conn_pid), key} do if conn.retries == retries do - Logger.debug("closing conn if retries is eq #{inspect(conn_pid)}") :ok = Gun.close(conn.conn) put_in( @@ -240,18 +227,13 @@ def handle_info({:gun_down, conn_pid, _protocol, _reason, _killed}, state) do end else {false, key} -> - # gun can send gun_down for closed conn, maybe connection is not closed yet - Logger.debug(":gun_down message for closed conn #{inspect(conn_pid)}") - put_in( state.conns, Map.delete(state.conns, key) ) nil -> - Logger.debug(":gun_down message for conn which is not found in state") - - :ok = Gun.close(conn_pid) + Logger.debug(":gun_down for conn which isn't found in state") state end @@ -275,7 +257,7 @@ def handle_info({:DOWN, _ref, :process, conn_pid, reason}, state) do ) else nil -> - Logger.debug(":DOWN message for conn which is not found in state") + Logger.debug(":DOWN for conn which isn't found in state") state end @@ -283,18 +265,6 @@ def handle_info({:DOWN, _ref, :process, conn_pid, reason}, state) do {:noreply, state} end - defp compose_key_gun_info(pid) do - %{origin_host: origin_host, origin_scheme: scheme, origin_port: port} = Gun.info(pid) - - host = - case :inet.ntoa(origin_host) do - {:error, :einval} -> origin_host - ip -> ip - end - - "#{scheme}:#{host}:#{port}" - end - defp find_conn(conns, conn_pid) do Enum.find(conns, fn {_key, conn} -> conn.conn == conn_pid @@ -310,8 +280,4 @@ defp find_conn(conns, conn_pid, conn_key) do def crf(current, steps, crf) do 1 + :math.pow(0.5, current / steps) * crf end - - def compose_uri_log(%URI{scheme: scheme, host: host, path: path}) do - "#{scheme}://#{host}#{path}" - end end diff --git a/test/http/adapter_helper/gun_test.exs b/test/http/adapter_helper/gun_test.exs index 6af8be15d..18025b986 100644 --- a/test/http/adapter_helper/gun_test.exs +++ b/test/http/adapter_helper/gun_test.exs @@ -6,7 +6,6 @@ defmodule Pleroma.HTTP.AdapterHelper.GunTest do use ExUnit.Case, async: true use Pleroma.Tests.Helpers - import ExUnit.CaptureLog import Mox alias Pleroma.Config @@ -63,7 +62,6 @@ test "https url with non standart port" do opts = Gun.options([receive_conn: false], uri) assert opts[:certificates_verification] - assert opts[:transport] == :tls end test "get conn on next request" do @@ -73,14 +71,12 @@ test "get conn on next request" do on_exit(fn -> Logger.configure(level: level) end) uri = URI.parse("http://some-domain2.com") - assert capture_log(fn -> - opts = Gun.options(uri) + opts = Gun.options(uri) - assert opts[:conn] == nil - assert opts[:close_conn] == nil - end) =~ - "Gun connections pool checkin was not successful. Trying to open conn for next request." + assert opts[:conn] == nil + assert opts[:close_conn] == nil + Process.sleep(50) opts = Gun.options(uri) assert is_pid(opts[:conn]) diff --git a/test/pool/connections_test.exs b/test/pool/connections_test.exs index 06f32b74e..aeda54875 100644 --- a/test/pool/connections_test.exs +++ b/test/pool/connections_test.exs @@ -355,7 +355,7 @@ test "connection can't get up", %{name: name} do refute Conn.open(url, name) refute Connections.checkin(url, name) end) =~ - "Received error on opening connection http://gun-not-up.com {:error, :timeout}" + "Opening connection to http://gun-not-up.com failed with error {:error, :timeout}" end test "process gun_down message and then gun_up", %{name: name} do From 98ed0d1c4bd2db354154cc4a1d1e6530eb68f499 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 13 Mar 2020 09:37:57 +0300 Subject: [PATCH 082/188] more clean up --- lib/pleroma/http/adapter_helper/gun.ex | 2 +- lib/pleroma/http/adapter_helper/hackney.ex | 2 +- lib/pleroma/http/connection.ex | 12 +++++++----- lib/pleroma/pool/request.ex | 1 - lib/pleroma/pool/supervisor.ex | 15 ++++++--------- lib/pleroma/reverse_proxy/client/tesla.ex | 9 +++++---- lib/pleroma/reverse_proxy/reverse_proxy.ex | 2 +- 7 files changed, 21 insertions(+), 22 deletions(-) diff --git a/lib/pleroma/http/adapter_helper/gun.ex b/lib/pleroma/http/adapter_helper/gun.ex index f14b95c19..ead7cdc6b 100644 --- a/lib/pleroma/http/adapter_helper/gun.ex +++ b/lib/pleroma/http/adapter_helper/gun.ex @@ -22,7 +22,7 @@ defmodule Pleroma.HTTP.AdapterHelper.Gun do @spec options(keyword(), URI.t()) :: keyword() def options(incoming_opts \\ [], %URI{} = uri) do proxy = - Pleroma.Config.get([:http, :proxy_url], nil) + Pleroma.Config.get([:http, :proxy_url]) |> AdapterHelper.format_proxy() config_opts = Pleroma.Config.get([:http, :adapter], []) diff --git a/lib/pleroma/http/adapter_helper/hackney.ex b/lib/pleroma/http/adapter_helper/hackney.ex index d08afae0c..dcb4cac71 100644 --- a/lib/pleroma/http/adapter_helper/hackney.ex +++ b/lib/pleroma/http/adapter_helper/hackney.ex @@ -11,7 +11,7 @@ defmodule Pleroma.HTTP.AdapterHelper.Hackney do @spec options(keyword(), URI.t()) :: keyword() def options(connection_opts \\ [], %URI{} = uri) do - proxy = Pleroma.Config.get([:http, :proxy_url], nil) + proxy = Pleroma.Config.get([:http, :proxy_url]) config_opts = Pleroma.Config.get([:http, :adapter], []) diff --git a/lib/pleroma/http/connection.ex b/lib/pleroma/http/connection.ex index 76de3fcfe..ebacf7902 100644 --- a/lib/pleroma/http/connection.ex +++ b/lib/pleroma/http/connection.ex @@ -30,12 +30,12 @@ def options(%URI{} = uri, opts \\ []) do @defaults |> pool_timeout() |> Keyword.merge(opts) - |> adapter().options(uri) + |> adapter_helper().options(uri) end defp pool_timeout(opts) do {config_key, default} = - if Application.get_env(:tesla, :adapter) == Tesla.Adapter.Gun do + if adapter() == Tesla.Adapter.Gun do {:pools, Config.get([:pools, :default, :timeout])} else {:hackney_pools, 10_000} @@ -47,10 +47,12 @@ defp pool_timeout(opts) do end @spec after_request(keyword()) :: :ok - def after_request(opts), do: adapter().after_request(opts) + def after_request(opts), do: adapter_helper().after_request(opts) - defp adapter do - case Application.get_env(:tesla, :adapter) do + defp adapter, do: Application.get_env(:tesla, :adapter) + + defp adapter_helper do + case adapter() do Tesla.Adapter.Gun -> AdapterHelper.Gun Tesla.Adapter.Hackney -> AdapterHelper.Hackney _ -> AdapterHelper diff --git a/lib/pleroma/pool/request.ex b/lib/pleroma/pool/request.ex index db7c10c01..3fb930db7 100644 --- a/lib/pleroma/pool/request.ex +++ b/lib/pleroma/pool/request.ex @@ -39,7 +39,6 @@ def handle_info({:gun_up, _conn, _protocol}, state) do @impl true def handle_info({:gun_down, _conn, _protocol, _reason, _killed}, state) do - # don't flush messages here, because gun can reconnect {:noreply, state} end diff --git a/lib/pleroma/pool/supervisor.ex b/lib/pleroma/pool/supervisor.ex index 8dc5b64b7..faf646cb2 100644 --- a/lib/pleroma/pool/supervisor.ex +++ b/lib/pleroma/pool/supervisor.ex @@ -13,16 +13,13 @@ def start_link(args) do end def init(_) do - children = - [ - %{ - id: Pool.Connections, - start: - {Pool.Connections, :start_link, [{:gun_connections, Config.get([:connections_pool])}]} - } - ] ++ pools() + conns_child = %{ + id: Pool.Connections, + start: + {Pool.Connections, :start_link, [{:gun_connections, Config.get([:connections_pool])}]} + } - Supervisor.init(children, strategy: :one_for_one) + Supervisor.init([conns_child | pools()], strategy: :one_for_one) end defp pools do diff --git a/lib/pleroma/reverse_proxy/client/tesla.ex b/lib/pleroma/reverse_proxy/client/tesla.ex index dbc6b66a3..e81ea8bde 100644 --- a/lib/pleroma/reverse_proxy/client/tesla.ex +++ b/lib/pleroma/reverse_proxy/client/tesla.ex @@ -3,11 +3,11 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.ReverseProxy.Client.Tesla do + @behaviour Pleroma.ReverseProxy.Client + @type headers() :: [{String.t(), String.t()}] @type status() :: pos_integer() - @behaviour Pleroma.ReverseProxy.Client - @spec request(atom(), String.t(), headers(), String.t(), keyword()) :: {:ok, status(), headers} | {:ok, status(), headers, map()} @@ -18,7 +18,7 @@ defmodule Pleroma.ReverseProxy.Client.Tesla do def request(method, url, headers, body, opts \\ []) do check_adapter() - opts = Keyword.merge(opts, body_as: :chunks) + opts = Keyword.put(opts, :body_as, :chunks) with {:ok, response} <- Pleroma.HTTP.request( @@ -39,7 +39,8 @@ def request(method, url, headers, body, opts \\ []) do end @impl true - @spec stream_body(map()) :: {:ok, binary(), map()} | {:error, atom() | String.t()} | :done + @spec stream_body(map()) :: + {:ok, binary(), map()} | {:error, atom() | String.t()} | :done | no_return() def stream_body(%{pid: pid, opts: opts, fin: true}) do # if connection was reused, but in tesla were redirects, # tesla returns new opened connection, which must be closed manually diff --git a/lib/pleroma/reverse_proxy/reverse_proxy.ex b/lib/pleroma/reverse_proxy/reverse_proxy.ex index 8f1aa3200..35b973b56 100644 --- a/lib/pleroma/reverse_proxy/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy/reverse_proxy.ex @@ -59,7 +59,7 @@ defmodule Pleroma.ReverseProxy do * `req_headers`, `resp_headers` additional headers. - * `http`: options for [gun](https://github.com/ninenines/gun). + * `http`: options for [hackney](https://github.com/benoitc/hackney) or [gun](https://github.com/ninenines/gun). """ @default_options [pool: :media] From 7c8003c3fcdcab075b9722ab236bf2d1d0e0e8cd Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sun, 15 Mar 2020 21:00:12 +0300 Subject: [PATCH 083/188] [#1364] Improved control over generation / sending of notifications. Fixed blocking / muting users notifications issue. Added tests. --- lib/pleroma/activity.ex | 10 ++ lib/pleroma/notification.ex | 129 +++++++++++++----- lib/pleroma/thread_mute.ex | 37 ++++- lib/pleroma/user.ex | 50 +++++-- lib/pleroma/user_relationship.ex | 9 +- .../web/activity_pub/transmogrifier.ex | 8 +- test/notification_test.exs | 112 ++++++++++++++- 7 files changed, 292 insertions(+), 63 deletions(-) diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 6ca05f74e..bbaa561a7 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -95,6 +95,16 @@ def with_preloaded_object(query, join_type \\ :inner) do |> preload([activity, object: object], object: object) end + def user_actor(%Activity{actor: nil}), do: nil + + def user_actor(%Activity{} = activity) do + with %User{} <- activity.user_actor do + activity.user_actor + else + _ -> User.get_cached_by_ap_id(activity.actor) + end + end + def with_joined_user_actor(query, join_type \\ :inner) do join(query, join_type, [activity], u in User, on: u.ap_id == activity.actor, diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 60dba3434..0d7a6610a 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Notification do alias Pleroma.Object alias Pleroma.Pagination alias Pleroma.Repo + alias Pleroma.ThreadMute alias Pleroma.User alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.Push @@ -17,6 +18,7 @@ defmodule Pleroma.Notification do import Ecto.Query import Ecto.Changeset + require Logger @type t :: %__MODULE__{} @@ -101,7 +103,7 @@ defp exclude_notification_muted(query, user, opts) do query |> where([n, a], a.actor not in ^notification_muted_ap_ids) - |> join(:left, [n, a], tm in Pleroma.ThreadMute, + |> join(:left, [n, a], tm in ThreadMute, on: tm.user_id == ^user.id and tm.context == fragment("?->>'context'", a.data) ) |> where([n, a, o, tm], is_nil(tm.user_id)) @@ -284,58 +286,108 @@ def dismiss(%{id: user_id} = _user, id) do def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity) do object = Object.normalize(activity) - unless object && object.data["type"] == "Answer" do - users = get_notified_from_activity(activity) - notifications = Enum.map(users, fn user -> create_notification(activity, user) end) - {:ok, notifications} - else + if object && object.data["type"] == "Answer" do {:ok, []} + else + do_create_notifications(activity) end end def create_notifications(%Activity{data: %{"type" => type}} = activity) when type in ["Like", "Announce", "Follow", "Move", "EmojiReact"] do - notifications = - activity - |> get_notified_from_activity() - |> Enum.map(&create_notification(activity, &1)) - - {:ok, notifications} + do_create_notifications(activity) end def create_notifications(_), do: {:ok, []} + defp do_create_notifications(%Activity{} = activity) do + {enabled_receivers, disabled_receivers} = get_notified_from_activity(activity) + potential_receivers = enabled_receivers ++ disabled_receivers + + notifications = + Enum.map(potential_receivers, fn user -> + do_send = user in enabled_receivers + create_notification(activity, user, do_send) + end) + + {:ok, notifications} + end + # TODO move to sql, too. - def create_notification(%Activity{} = activity, %User{} = user) do + def create_notification(%Activity{} = activity, %User{} = user, do_send \\ true) do unless skip?(activity, user) do notification = %Notification{user_id: user.id, activity: activity} {:ok, notification} = Repo.insert(notification) - ["user", "user:notification"] - |> Streamer.stream(notification) + if do_send do + Streamer.stream(["user", "user:notification"], notification) + Push.send(notification) + end - Push.send(notification) notification end end + @doc """ + Returns a tuple with 2 elements: + {enabled notification receivers, currently disabled receivers (blocking / [thread] muting)} + """ def get_notified_from_activity(activity, local_only \\ true) def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, local_only) when type in ["Create", "Like", "Announce", "Follow", "Move", "EmojiReact"] do - [] - |> Utils.maybe_notify_to_recipients(activity) - |> Utils.maybe_notify_mentioned_recipients(activity) - |> Utils.maybe_notify_subscribers(activity) - |> Utils.maybe_notify_followers(activity) - |> Enum.uniq() - |> User.get_users_from_set(local_only) + potential_receiver_ap_ids = + [] + |> Utils.maybe_notify_to_recipients(activity) + |> Utils.maybe_notify_mentioned_recipients(activity) + |> Utils.maybe_notify_subscribers(activity) + |> Utils.maybe_notify_followers(activity) + |> Enum.uniq() + + notification_enabled_ap_ids = + potential_receiver_ap_ids + |> exclude_relation_restricting_ap_ids(activity) + |> exclude_thread_muter_ap_ids(activity) + + potential_receivers = + potential_receiver_ap_ids + |> Enum.uniq() + |> User.get_users_from_set(local_only) + + notification_enabled_users = + Enum.filter(potential_receivers, fn u -> u.ap_id in notification_enabled_ap_ids end) + + {notification_enabled_users, potential_receivers -- notification_enabled_users} end - def get_notified_from_activity(_, _local_only), do: [] + def get_notified_from_activity(_, _local_only), do: {[], []} + + @doc "Filters out AP IDs of users basing on their relationships with activity actor user" + def exclude_relation_restricting_ap_ids([], _activity), do: [] + + def exclude_relation_restricting_ap_ids(ap_ids, %Activity{} = activity) do + relation_restricted_ap_ids = + activity + |> Activity.user_actor() + |> User.incoming_relations_ungrouped_ap_ids([ + :block, + :notification_mute + ]) + + Enum.uniq(ap_ids) -- relation_restricted_ap_ids + end + + @doc "Filters out AP IDs of users who mute activity thread" + def exclude_thread_muter_ap_ids([], _activity), do: [] + + def exclude_thread_muter_ap_ids(ap_ids, %Activity{} = activity) do + thread_muter_ap_ids = ThreadMute.muter_ap_ids(activity.data["context"]) + + Enum.uniq(ap_ids) -- thread_muter_ap_ids + end @spec skip?(Activity.t(), User.t()) :: boolean() - def skip?(activity, user) do + def skip?(%Activity{} = activity, %User{} = user) do [ :self, :followers, @@ -344,18 +396,20 @@ def skip?(activity, user) do :non_follows, :recently_followed ] - |> Enum.any?(&skip?(&1, activity, user)) + |> Enum.find(&skip?(&1, activity, user)) end + def skip?(_, _), do: false + @spec skip?(atom(), Activity.t(), User.t()) :: boolean() - def skip?(:self, activity, user) do + def skip?(:self, %Activity{} = activity, %User{} = user) do activity.data["actor"] == user.ap_id end def skip?( :followers, - activity, - %{notification_settings: %{followers: false}} = user + %Activity{} = activity, + %User{notification_settings: %{followers: false}} = user ) do actor = activity.data["actor"] follower = User.get_cached_by_ap_id(actor) @@ -364,15 +418,19 @@ def skip?( def skip?( :non_followers, - activity, - %{notification_settings: %{non_followers: false}} = user + %Activity{} = activity, + %User{notification_settings: %{non_followers: false}} = user ) do actor = activity.data["actor"] follower = User.get_cached_by_ap_id(actor) !User.following?(follower, user) end - def skip?(:follows, activity, %{notification_settings: %{follows: false}} = user) do + def skip?( + :follows, + %Activity{} = activity, + %User{notification_settings: %{follows: false}} = user + ) do actor = activity.data["actor"] followed = User.get_cached_by_ap_id(actor) User.following?(user, followed) @@ -380,15 +438,16 @@ def skip?(:follows, activity, %{notification_settings: %{follows: false}} = user def skip?( :non_follows, - activity, - %{notification_settings: %{non_follows: false}} = user + %Activity{} = activity, + %User{notification_settings: %{non_follows: false}} = user ) do actor = activity.data["actor"] followed = User.get_cached_by_ap_id(actor) !User.following?(user, followed) end - def skip?(:recently_followed, %{data: %{"type" => "Follow"}} = activity, user) do + # To do: consider defining recency in hours and checking FollowingRelationship with a single SQL + def skip?(:recently_followed, %Activity{data: %{"type" => "Follow"}} = activity, %User{} = user) do actor = activity.data["actor"] Notification.for_user(user) diff --git a/lib/pleroma/thread_mute.ex b/lib/pleroma/thread_mute.ex index cc815430a..2b4cf02cf 100644 --- a/lib/pleroma/thread_mute.ex +++ b/lib/pleroma/thread_mute.ex @@ -9,7 +9,8 @@ defmodule Pleroma.ThreadMute do alias Pleroma.ThreadMute alias Pleroma.User - require Ecto.Query + import Ecto.Changeset + import Ecto.Query schema "thread_mutes" do belongs_to(:user, User, type: FlakeId.Ecto.CompatType) @@ -18,19 +19,43 @@ defmodule Pleroma.ThreadMute do def changeset(mute, params \\ %{}) do mute - |> Ecto.Changeset.cast(params, [:user_id, :context]) - |> Ecto.Changeset.foreign_key_constraint(:user_id) - |> Ecto.Changeset.unique_constraint(:user_id, name: :unique_index) + |> cast(params, [:user_id, :context]) + |> foreign_key_constraint(:user_id) + |> unique_constraint(:user_id, name: :unique_index) end def query(user_id, context) do {:ok, user_id} = FlakeId.Ecto.CompatType.dump(user_id) ThreadMute - |> Ecto.Query.where(user_id: ^user_id) - |> Ecto.Query.where(context: ^context) + |> where(user_id: ^user_id) + |> where(context: ^context) end + def muters_query(context) do + ThreadMute + |> join(:inner, [tm], u in assoc(tm, :user)) + |> where([tm], tm.context == ^context) + |> select([tm, u], u.ap_id) + end + + def muter_ap_ids(context, ap_ids \\ nil) + + def muter_ap_ids(context, ap_ids) when context not in [nil, ""] do + context + |> muters_query() + |> maybe_filter_on_ap_id(ap_ids) + |> Repo.all() + end + + def muter_ap_ids(_context, _ap_ids), do: [] + + defp maybe_filter_on_ap_id(query, ap_ids) when is_list(ap_ids) do + where(query, [tm, u], u.ap_id in ^ap_ids) + end + + defp maybe_filter_on_ap_id(query, _ap_ids), do: query + def add_mute(user_id, context) do %ThreadMute{} |> changeset(%{user_id: user_id, context: context}) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index db510d957..8c8ecfe35 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -149,22 +149,26 @@ defmodule Pleroma.User do {outgoing_relation, outgoing_relation_target}, {incoming_relation, incoming_relation_source} ]} <- @user_relationships_config do - # Definitions of `has_many :blocker_blocks`, `has_many :muter_mutes` etc. + # Definitions of `has_many` relations: :blocker_blocks, :muter_mutes, :reblog_muter_mutes, + # :notification_muter_mutes, :subscribee_subscriptions has_many(outgoing_relation, UserRelationship, foreign_key: :source_id, where: [relationship_type: relationship_type] ) - # Definitions of `has_many :blockee_blocks`, `has_many :mutee_mutes` etc. + # Definitions of `has_many` relations: :blockee_blocks, :mutee_mutes, :reblog_mutee_mutes, + # :notification_mutee_mutes, :subscriber_subscriptions has_many(incoming_relation, UserRelationship, foreign_key: :target_id, where: [relationship_type: relationship_type] ) - # Definitions of `has_many :blocked_users`, `has_many :muted_users` etc. + # Definitions of `has_many` relations: :blocked_users, :muted_users, :reblog_muted_users, + # :notification_muted_users, :subscriber_users has_many(outgoing_relation_target, through: [outgoing_relation, :target]) - # Definitions of `has_many :blocker_users`, `has_many :muter_users` etc. + # Definitions of `has_many` relations: :blocker_users, :muter_users, :reblog_muter_users, + # :notification_muter_users, :subscribee_users has_many(incoming_relation_source, through: [incoming_relation, :source]) end @@ -184,7 +188,9 @@ defmodule Pleroma.User do for {_relationship_type, [{_outgoing_relation, outgoing_relation_target}, _]} <- @user_relationships_config do - # Definitions of `blocked_users_relation/1`, `muted_users_relation/1`, etc. + # `def blocked_users_relation/2`, `def muted_users_relation/2`, + # `def reblog_muted_users_relation/2`, `def notification_muted_users/2`, + # `def subscriber_users/2` def unquote(:"#{outgoing_relation_target}_relation")(user, restrict_deactivated? \\ false) do target_users_query = assoc(user, unquote(outgoing_relation_target)) @@ -195,7 +201,8 @@ def unquote(:"#{outgoing_relation_target}_relation")(user, restrict_deactivated? end end - # Definitions of `blocked_users/1`, `muted_users/1`, etc. + # `def blocked_users/2`, `def muted_users/2`, `def reblog_muted_users/2`, + # `def notification_muted_users/2`, `def subscriber_users/2` def unquote(outgoing_relation_target)(user, restrict_deactivated? \\ false) do __MODULE__ |> apply(unquote(:"#{outgoing_relation_target}_relation"), [ @@ -205,7 +212,8 @@ def unquote(outgoing_relation_target)(user, restrict_deactivated? \\ false) do |> Repo.all() end - # Definitions of `blocked_users_ap_ids/1`, `muted_users_ap_ids/1`, etc. + # `def blocked_users_ap_ids/2`, `def muted_users_ap_ids/2`, `def reblog_muted_users_ap_ids/2`, + # `def notification_muted_users_ap_ids/2`, `def subscriber_users_ap_ids/2` def unquote(:"#{outgoing_relation_target}_ap_ids")(user, restrict_deactivated? \\ false) do __MODULE__ |> apply(unquote(:"#{outgoing_relation_target}_relation"), [ @@ -1217,7 +1225,9 @@ def subscribed_to?(%User{} = user, %{ap_id: ap_id}) do E.g. `outgoing_relations_ap_ids(user, [:block])` -> `%{block: ["https://some.site/users/userapid"]}` """ @spec outgoing_relations_ap_ids(User.t(), list(atom())) :: %{atom() => list(String.t())} - def outgoing_relations_ap_ids(_, []), do: %{} + def outgoing_relations_ap_ids(_user, []), do: %{} + + def outgoing_relations_ap_ids(nil, _relationship_types), do: %{} def outgoing_relations_ap_ids(%User{} = user, relationship_types) when is_list(relationship_types) do @@ -1238,6 +1248,30 @@ def outgoing_relations_ap_ids(%User{} = user, relationship_types) ) end + def incoming_relations_ungrouped_ap_ids(user, relationship_types, ap_ids \\ nil) + + def incoming_relations_ungrouped_ap_ids(_user, [], _ap_ids), do: [] + + def incoming_relations_ungrouped_ap_ids(nil, _relationship_types, _ap_ids), do: [] + + def incoming_relations_ungrouped_ap_ids(%User{} = user, relationship_types, ap_ids) + when is_list(relationship_types) do + user + |> assoc(:incoming_relationships) + |> join(:inner, [user_rel], u in assoc(user_rel, :source)) + |> where([user_rel, u], user_rel.relationship_type in ^relationship_types) + |> maybe_filter_on_ap_id(ap_ids) + |> select([user_rel, u], u.ap_id) + |> distinct(true) + |> Repo.all() + end + + defp maybe_filter_on_ap_id(query, ap_ids) when is_list(ap_ids) do + where(query, [user_rel, u], u.ap_id in ^ap_ids) + end + + defp maybe_filter_on_ap_id(query, _ap_ids), do: query + def deactivate_async(user, status \\ true) do BackgroundWorker.enqueue("deactivate_user", %{"user_id" => user.id, "status" => status}) end diff --git a/lib/pleroma/user_relationship.ex b/lib/pleroma/user_relationship.ex index 393947942..01b6ace9d 100644 --- a/lib/pleroma/user_relationship.ex +++ b/lib/pleroma/user_relationship.ex @@ -21,15 +21,18 @@ defmodule Pleroma.UserRelationship do end for relationship_type <- Keyword.keys(UserRelationshipTypeEnum.__enum_map__()) do - # Definitions of `create_block/2`, `create_mute/2` etc. + # `def create_block/2`, `def create_mute/2`, `def create_reblog_mute/2`, + # `def create_notification_mute/2`, `def create_inverse_subscription/2` def unquote(:"create_#{relationship_type}")(source, target), do: create(unquote(relationship_type), source, target) - # Definitions of `delete_block/2`, `delete_mute/2` etc. + # `def delete_block/2`, `def delete_mute/2`, `def delete_reblog_mute/2`, + # `def delete_notification_mute/2`, `def delete_inverse_subscription/2` def unquote(:"delete_#{relationship_type}")(source, target), do: delete(unquote(relationship_type), source, target) - # Definitions of `block_exists?/2`, `mute_exists?/2` etc. + # `def block_exists?/2`, `def mute_exists?/2`, `def reblog_mute_exists?/2`, + # `def notification_mute_exists?/2`, `def inverse_subscription_exists?/2` def unquote(:"#{relationship_type}_exists?")(source, target), do: exists?(unquote(relationship_type), source, target) end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 9cd3de705..d6549a932 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -1108,13 +1108,11 @@ def add_hashtags(object) do end def add_mention_tags(object) do - mentions = - object - |> Utils.get_notified_from_object() - |> Enum.map(&build_mention_tag/1) + {enabled_receivers, disabled_receivers} = Utils.get_notified_from_object(object) + potential_receivers = enabled_receivers ++ disabled_receivers + mentions = Enum.map(potential_receivers, &build_mention_tag/1) tags = object["tag"] || [] - Map.put(object, "tag", tags ++ mentions) end diff --git a/test/notification_test.exs b/test/notification_test.exs index 56a581810..bc2d80f05 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -6,12 +6,14 @@ defmodule Pleroma.NotificationTest do use Pleroma.DataCase import Pleroma.Factory + import Mock alias Pleroma.Notification alias Pleroma.Tests.ObanHelpers alias Pleroma.User alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Push alias Pleroma.Web.Streamer describe "create_notifications" do @@ -382,7 +384,7 @@ test "Returns recent notifications" do end end - describe "notification target determination" do + describe "notification target determination / get_notified_from_activity/2" do test "it sends notifications to addressed users in new messages" do user = insert(:user) other_user = insert(:user) @@ -392,7 +394,9 @@ test "it sends notifications to addressed users in new messages" do "status" => "hey @#{other_user.nickname}!" }) - assert other_user in Notification.get_notified_from_activity(activity) + {enabled_receivers, _disabled_receivers} = Notification.get_notified_from_activity(activity) + + assert other_user in enabled_receivers end test "it sends notifications to mentioned users in new messages" do @@ -420,7 +424,9 @@ test "it sends notifications to mentioned users in new messages" do {:ok, activity} = Transmogrifier.handle_incoming(create_activity) - assert other_user in Notification.get_notified_from_activity(activity) + {enabled_receivers, _disabled_receivers} = Notification.get_notified_from_activity(activity) + + assert other_user in enabled_receivers end test "it does not send notifications to users who are only cc in new messages" do @@ -442,7 +448,9 @@ test "it does not send notifications to users who are only cc in new messages" d {:ok, activity} = Transmogrifier.handle_incoming(create_activity) - assert other_user not in Notification.get_notified_from_activity(activity) + {enabled_receivers, _disabled_receivers} = Notification.get_notified_from_activity(activity) + + assert other_user not in enabled_receivers end test "it does not send notification to mentioned users in likes" do @@ -457,7 +465,10 @@ test "it does not send notification to mentioned users in likes" do {:ok, activity_two, _} = CommonAPI.favorite(activity_one.id, third_user) - assert other_user not in Notification.get_notified_from_activity(activity_two) + {enabled_receivers, _disabled_receivers} = + Notification.get_notified_from_activity(activity_two) + + assert other_user not in enabled_receivers end test "it does not send notification to mentioned users in announces" do @@ -472,7 +483,96 @@ test "it does not send notification to mentioned users in announces" do {:ok, activity_two, _} = CommonAPI.repeat(activity_one.id, third_user) - assert other_user not in Notification.get_notified_from_activity(activity_two) + {enabled_receivers, _disabled_receivers} = + Notification.get_notified_from_activity(activity_two) + + assert other_user not in enabled_receivers + end + + test_with_mock "it returns blocking recipient in disabled recipients list", + Push, + [:passthrough], + [] do + user = insert(:user) + other_user = insert(:user) + {:ok, _user_relationship} = User.block(other_user, user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{other_user.nickname}!"}) + + {enabled_receivers, disabled_receivers} = Notification.get_notified_from_activity(activity) + + assert [] == enabled_receivers + assert [other_user] == disabled_receivers + + assert 1 == length(Repo.all(Notification)) + refute called(Push.send(:_)) + end + + test_with_mock "it returns notification-muting recipient in disabled recipients list", + Push, + [:passthrough], + [] do + user = insert(:user) + other_user = insert(:user) + {:ok, _user_relationships} = User.mute(other_user, user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{other_user.nickname}!"}) + + {enabled_receivers, disabled_receivers} = Notification.get_notified_from_activity(activity) + + assert [] == enabled_receivers + assert [other_user] == disabled_receivers + + assert 1 == length(Repo.all(Notification)) + refute called(Push.send(:_)) + end + + test_with_mock "it returns thread-muting recipient in disabled recipients list", + Push, + [:passthrough], + [] do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{other_user.nickname}!"}) + + {:ok, _} = CommonAPI.add_mute(other_user, activity) + + {:ok, same_context_activity} = + CommonAPI.post(user, %{ + "status" => "hey-hey-hey @#{other_user.nickname}!", + "in_reply_to_status_id" => activity.id + }) + + {enabled_receivers, disabled_receivers} = + Notification.get_notified_from_activity(same_context_activity) + + assert [other_user] == disabled_receivers + refute other_user in enabled_receivers + + [pre_mute_notification, post_mute_notification] = + Repo.all(from(n in Notification, where: n.user_id == ^other_user.id, order_by: n.id)) + + pre_mute_notification_id = pre_mute_notification.id + post_mute_notification_id = post_mute_notification.id + + assert called( + Push.send( + :meck.is(fn + %Notification{id: ^pre_mute_notification_id} -> true + _ -> false + end) + ) + ) + + refute called( + Push.send( + :meck.is(fn + %Notification{id: ^post_mute_notification_id} -> true + _ -> false + end) + ) + ) end end From 35471205f862fa069c6d87aefc1d827c9fab6e08 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 16 Mar 2020 15:47:25 +0300 Subject: [PATCH 084/188] temp fix for `:gun.info` MatchError --- lib/pleroma/pool/connections.ex | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/pool/connections.ex b/lib/pleroma/pool/connections.ex index 772833509..16aa80548 100644 --- a/lib/pleroma/pool/connections.ex +++ b/lib/pleroma/pool/connections.ex @@ -169,19 +169,26 @@ defp sort_conns({_, c1}, {_, c2}) do @impl true def handle_info({:gun_up, conn_pid, _protocol}, state) do - %{origin_host: host, origin_scheme: scheme, origin_port: port} = Gun.info(conn_pid) + # TODO: temp fix for gun MatchError https://github.com/ninenines/gun/issues/222 + # TODO: REMOVE LATER + {key, conn} = + try do + %{origin_host: host, origin_scheme: scheme, origin_port: port} = Gun.info(conn_pid) - host = - case :inet.ntoa(host) do - {:error, :einval} -> host - ip -> ip + host = + case :inet.ntoa(host) do + {:error, :einval} -> host + ip -> ip + end + + key = "#{scheme}:#{host}:#{port}" + find_conn(state.conns, conn_pid, key) + rescue + MatcheError -> find_conn(state.conns, conn_pid) end - key = "#{scheme}:#{host}:#{port}" - state = - with {_key, conn} <- find_conn(state.conns, conn_pid, key), - {true, key} <- {Process.alive?(conn_pid), key} do + with {true, key} <- {Process.alive?(conn_pid), key} do put_in(state.conns[key], %{ conn | gun_state: :up, From bf474ca3c154544b54720ea23c06191e68f32522 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 16 Mar 2020 16:23:49 +0300 Subject: [PATCH 085/188] fix --- lib/pleroma/pool/connections.ex | 34 +++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/lib/pleroma/pool/connections.ex b/lib/pleroma/pool/connections.ex index 16aa80548..91102faf7 100644 --- a/lib/pleroma/pool/connections.ex +++ b/lib/pleroma/pool/connections.ex @@ -167,28 +167,30 @@ defp sort_conns({_, c1}, {_, c2}) do c1.crf <= c2.crf and c1.last_reference <= c2.last_reference end - @impl true - def handle_info({:gun_up, conn_pid, _protocol}, state) do + defp find_conn_from_gun_info(conns, pid) do # TODO: temp fix for gun MatchError https://github.com/ninenines/gun/issues/222 # TODO: REMOVE LATER - {key, conn} = - try do - %{origin_host: host, origin_scheme: scheme, origin_port: port} = Gun.info(conn_pid) + try do + %{origin_host: host, origin_scheme: scheme, origin_port: port} = Gun.info(pid) - host = - case :inet.ntoa(host) do - {:error, :einval} -> host - ip -> ip - end + host = + case :inet.ntoa(host) do + {:error, :einval} -> host + ip -> ip + end - key = "#{scheme}:#{host}:#{port}" - find_conn(state.conns, conn_pid, key) - rescue - MatcheError -> find_conn(state.conns, conn_pid) - end + key = "#{scheme}:#{host}:#{port}" + find_conn(conns, pid, key) + rescue + MatcheError -> find_conn(conns, pid) + end + end + @impl true + def handle_info({:gun_up, conn_pid, _protocol}, state) do state = - with {true, key} <- {Process.alive?(conn_pid), key} do + with {key, conn} <- find_conn_from_gun_info(state.conns, conn_pid), + {true, key} <- {Process.alive?(conn_pid), key} do put_in(state.conns[key], %{ conn | gun_state: :up, From d198e7fa2a0c92be4e99c5a765de85096d318bfe Mon Sep 17 00:00:00 2001 From: eugenijm Date: Tue, 28 Jan 2020 09:47:59 +0300 Subject: [PATCH 086/188] Admin API: `PATCH /api/pleroma/admin/users/:nickname/change_password` --- CHANGELOG.md | 1 + docs/API/admin_api.md | 8 +++++ lib/pleroma/moderation_log.ex | 11 +++++++ .../web/admin_api/admin_api_controller.ex | 33 +++++++++++++++++++ lib/pleroma/web/router.ex | 1 + .../admin_api/admin_api_controller_test.exs | 26 +++++++++++++++ 6 files changed, 80 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4168086e2..0f8091c8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Mastodon API: User timelines will now respect blocks, unless you are getting the user timeline of somebody you blocked (which would be empty otherwise). - Mastodon API: Favoriting / Repeating a post multiple times will now return the identical response every time. Before, executing that action twice would return an error ("already favorited") on the second try. - Mastodon API: Limit timeline requests to 3 per timeline per 500ms per user/ip by default. +- Admin API: `PATCH /api/pleroma/admin/users/:nickname/change_password`
### Added diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index 47afdfba5..cb8201f11 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -414,6 +414,14 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret - `nicknames` - Response: none (code `204`) +## `PATCH /api/pleroma/admin/users/:nickname/change_password` + +### Change the user password + +- Params: + - `new_password` +- Response: none (code `200`) + ## `GET /api/pleroma/admin/reports` ### Get a list of reports diff --git a/lib/pleroma/moderation_log.ex b/lib/pleroma/moderation_log.ex index e32895f70..b5435a553 100644 --- a/lib/pleroma/moderation_log.ex +++ b/lib/pleroma/moderation_log.ex @@ -605,6 +605,17 @@ def get_log_entry_message(%ModerationLog{ }" end + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "change_password", + "subject" => subjects + } + }) do + "@#{actor_nickname} changed password for users: #{users_to_nicknames_string(subjects)}" + end + defp nicknames_to_string(nicknames) do nicknames |> Enum.map(&"@#{&1}") diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 175260bc2..2aa2c6ac2 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -658,6 +658,39 @@ def force_password_reset(%{assigns: %{user: admin}} = conn, %{"nicknames" => nic json_response(conn, :no_content, "") end + @doc "Changes password for a given user" + def change_password(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname} = params) do + with {_, user} <- {:user, User.get_cached_by_nickname(nickname)}, + {:ok, _user} <- + User.reset_password(user, %{ + password: params["new_password"], + password_confirmation: params["new_password"] + }) do + ModerationLog.insert_log(%{ + actor: admin, + subject: [user], + action: "change_password" + }) + + User.force_password_reset_async(user) + + ModerationLog.insert_log(%{ + actor: admin, + subject: [user], + action: "force_password_reset" + }) + + json(conn, %{status: "success"}) + else + {:error, changeset} -> + {_, {error, _}} = Enum.at(changeset.errors, 0) + json(conn, %{error: "New password #{error}."}) + + _ -> + json(conn, %{error: "Unable to change password."}) + end + end + def list_reports(conn, params) do {page, page_size} = page_params(params) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index e4e3ee704..c03ad101e 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -173,6 +173,7 @@ defmodule Pleroma.Web.Router do get("/users/:nickname/password_reset", AdminAPIController, :get_password_reset) patch("/users/force_password_reset", AdminAPIController, :force_password_reset) + patch("/users/:nickname/change_password", AdminAPIController, :change_password) get("/users", AdminAPIController, :list_users) get("/users/:nickname", AdminAPIController, :user_show) diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index e4c152fb7..0c1214f05 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -3389,6 +3389,32 @@ test "returns log filtered by search", %{conn: conn, moderator: moderator} do end end + describe "PATCH /users/:nickname/change_password" do + test "changes password", %{conn: conn, admin: admin} do + user = insert(:user) + assert user.password_reset_pending == false + + conn = + patch(conn, "/api/pleroma/admin/users/#{user.nickname}/change_password", %{ + "new_password" => "password" + }) + + assert json_response(conn, 200) == %{"status" => "success"} + + ObanHelpers.perform_all() + + assert User.get_by_id(user.id).password_reset_pending == true + + [log_entry1, log_entry2] = ModerationLog |> Repo.all() |> Enum.sort() + + assert ModerationLog.get_log_entry_message(log_entry1) == + "@#{admin.nickname} changed password for users: @#{user.nickname}" + + assert ModerationLog.get_log_entry_message(log_entry2) == + "@#{admin.nickname} forced password reset for users: @#{user.nickname}" + end + end + describe "PATCH /users/:nickname/force_password_reset" do test "sets password_reset_pending to true", %{conn: conn} do user = insert(:user) From 13cce9c0debbf9a80ed5da26cb34ca563e5e1417 Mon Sep 17 00:00:00 2001 From: eugenijm Date: Fri, 31 Jan 2020 21:07:46 +0300 Subject: [PATCH 087/188] Admin API: `PATCH /api/pleroma/admin/users/:nickname/credentials`, `GET /api/pleroma/admin/users/:nickname/credentials`. --- CHANGELOG.md | 2 +- docs/API/admin_api.md | 75 +++++++++++++++- lib/pleroma/moderation_log.ex | 4 +- lib/pleroma/user.ex | 86 ++++++++++++++++++- .../web/admin_api/admin_api_controller.ex | 34 +++++--- .../web/admin_api/views/account_view.ex | 40 +++++++++ .../controllers/account_controller.ex | 60 +++---------- lib/pleroma/web/router.ex | 3 +- .../admin_api/admin_api_controller_test.exs | 57 ++++++++++-- 9 files changed, 286 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f8091c8c..ec04c26e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,7 +67,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Mastodon API: User timelines will now respect blocks, unless you are getting the user timeline of somebody you blocked (which would be empty otherwise). - Mastodon API: Favoriting / Repeating a post multiple times will now return the identical response every time. Before, executing that action twice would return an error ("already favorited") on the second try. - Mastodon API: Limit timeline requests to 3 per timeline per 500ms per user/ip by default. -- Admin API: `PATCH /api/pleroma/admin/users/:nickname/change_password` +- Admin API: `PATCH /api/pleroma/admin/users/:nickname/credentials` and `GET /api/pleroma/admin/users/:nickname/credentials`
### Added diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index cb8201f11..edcf73e14 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -414,12 +414,81 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret - `nicknames` - Response: none (code `204`) -## `PATCH /api/pleroma/admin/users/:nickname/change_password` +## `GET /api/pleroma/admin/users/:nickname/credentials` -### Change the user password +### Get the user's email, password, display and settings-related fields - Params: - - `new_password` + - `nickname` + +- Response: + +```json +{ + "actor_type": "Person", + "allow_following_move": true, + "avatar": "https://pleroma.social/media/7e8e7508fd545ef580549b6881d80ec0ff2c81ed9ad37b9bdbbdf0e0d030159d.jpg", + "background": "https://pleroma.social/media/4de34c0bd10970d02cbdef8972bef0ebbf55f43cadc449554d4396156162fe9a.jpg", + "banner": "https://pleroma.social/media/8d92ba2bd244b613520abf557dd448adcd30f5587022813ee9dd068945986946.jpg", + "bio": "bio", + "default_scope": "public", + "discoverable": false, + "email": "user@example.com", + "fields": [ + { + "name": "example", + "value": "https://example.com" + } + ], + "hide_favorites": false, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "id": "9oouHaEEUR54hls968", + "locked": true, + "name": "user", + "no_rich_text": true, + "pleroma_settings_store": {}, + "raw_fields": [ + { + "id": 1, + "name": "example", + "value": "https://example.com" + }, + ], + "show_role": true, + "skip_thread_containment": false +} +``` + +## `PATCH /api/pleroma/admin/users/:nickname/credentials` + +### Change the user's email, password, display and settings-related fields + +- Params: + - `email` + - `password` + - `name` + - `bio` + - `avatar` + - `locked` + - `no_rich_text` + - `default_scope` + - `banner` + - `hide_follows` + - `hide_followers` + - `hide_followers_count` + - `hide_follows_count` + - `hide_favorites` + - `allow_following_move` + - `background` + - `show_role` + - `skip_thread_containment` + - `fields` + - `discoverable` + - `actor_type` + - Response: none (code `200`) ## `GET /api/pleroma/admin/reports` diff --git a/lib/pleroma/moderation_log.ex b/lib/pleroma/moderation_log.ex index b5435a553..7aacd9d80 100644 --- a/lib/pleroma/moderation_log.ex +++ b/lib/pleroma/moderation_log.ex @@ -609,11 +609,11 @@ def get_log_entry_message(%ModerationLog{ def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, - "action" => "change_password", + "action" => "updated_users", "subject" => subjects } }) do - "@#{actor_nickname} changed password for users: #{users_to_nicknames_string(subjects)}" + "@#{actor_nickname} updated users: #{users_to_nicknames_string(subjects)}" end defp nicknames_to_string(nicknames) do diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 911dde6e2..44de64345 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -417,9 +417,55 @@ def update_changeset(struct, params \\ %{}) do |> validate_format(:nickname, local_nickname_regex()) |> validate_length(:bio, max: bio_limit) |> validate_length(:name, min: 1, max: name_limit) + |> put_fields() + |> put_change_if_present(:bio, &{:ok, parse_bio(&1, struct)}) + |> put_change_if_present(:avatar, &put_upload(&1, :avatar)) + |> put_change_if_present(:banner, &put_upload(&1, :banner)) + |> put_change_if_present(:background, &put_upload(&1, :background)) + |> put_change_if_present( + :pleroma_settings_store, + &{:ok, Map.merge(struct.pleroma_settings_store, &1)} + ) |> validate_fields(false) end + defp put_fields(changeset) do + if raw_fields = get_change(changeset, :raw_fields) do + raw_fields = + raw_fields + |> Enum.filter(fn %{"name" => n} -> n != "" end) + + fields = + raw_fields + |> Enum.map(fn f -> Map.update!(f, "value", &AutoLinker.link(&1)) end) + + changeset + |> put_change(:raw_fields, raw_fields) + |> put_change(:fields, fields) + else + changeset + end + end + + defp put_change_if_present(changeset, map_field, value_function) do + if value = get_change(changeset, map_field) do + with {:ok, new_value} <- value_function.(value) do + put_change(changeset, map_field, new_value) + else + _ -> changeset + end + else + changeset + end + end + + defp put_upload(value, type) do + with %Plug.Upload{} <- value, + {:ok, object} <- ActivityPub.upload(value, type: type) do + {:ok, object.data} + end + end + def upgrade_changeset(struct, params \\ %{}, remote? \\ false) do bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000) name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) @@ -463,6 +509,27 @@ def upgrade_changeset(struct, params \\ %{}, remote? \\ false) do |> validate_fields(remote?) end + def update_as_admin_changeset(struct, params) do + struct + |> update_changeset(params) + |> cast(params, [:email]) + |> delete_change(:also_known_as) + |> unique_constraint(:email) + |> validate_format(:email, @email_regex) + end + + @spec update_as_admin(%User{}, map) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def update_as_admin(user, params) do + params = Map.put(params, "password_confirmation", params["password"]) + changeset = update_as_admin_changeset(user, params) + + if params["password"] do + reset_password(user, changeset, params) + else + User.update_and_set_cache(changeset) + end + end + def password_update_changeset(struct, params) do struct |> cast(params, [:password, :password_confirmation]) @@ -473,10 +540,14 @@ def password_update_changeset(struct, params) do end @spec reset_password(User.t(), map) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} - def reset_password(%User{id: user_id} = user, data) do + def reset_password(%User{} = user, params) do + reset_password(user, user, params) + end + + def reset_password(%User{id: user_id} = user, struct, params) do multi = Multi.new() - |> Multi.update(:user, password_update_changeset(user, data)) + |> Multi.update(:user, password_update_changeset(struct, params)) |> Multi.delete_all(:tokens, OAuth.Token.Query.get_by_user(user_id)) |> Multi.delete_all(:auth, OAuth.Authorization.delete_by_user_query(user)) @@ -1856,6 +1927,17 @@ def fields(%{fields: nil}), do: [] def fields(%{fields: fields}), do: fields + def sanitized_fields(%User{} = user) do + user + |> User.fields() + |> Enum.map(fn %{"name" => name, "value" => value} -> + %{ + "name" => name, + "value" => Pleroma.HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly) + } + end) + end + def validate_fields(changeset, remote? \\ false) do limit_name = if remote?, do: :max_remote_account_fields, else: :max_account_fields limit = Pleroma.Config.get([:instance, limit_name], 0) diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 2aa2c6ac2..0368df1e9 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -38,7 +38,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do plug( OAuthScopesPlug, %{scopes: ["read:accounts"], admin: true} - when action in [:list_users, :user_show, :right_get] + when action in [:list_users, :user_show, :right_get, :show_user_credentials] ) plug( @@ -54,7 +54,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do :tag_users, :untag_users, :right_add, - :right_delete + :right_delete, + :update_user_credentials ] ) @@ -658,21 +659,34 @@ def force_password_reset(%{assigns: %{user: admin}} = conn, %{"nicknames" => nic json_response(conn, :no_content, "") end - @doc "Changes password for a given user" - def change_password(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname} = params) do + @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 + conn + |> put_view(AccountView) + |> render("credentials.json", %{user: user, for: admin}) + else + _ -> {:error, :not_found} + end + end + + @doc "Updates a given user" + def update_user_credentials( + %{assigns: %{user: admin}} = conn, + %{"nickname" => nickname} = params + ) do with {_, user} <- {:user, User.get_cached_by_nickname(nickname)}, {:ok, _user} <- - User.reset_password(user, %{ - password: params["new_password"], - password_confirmation: params["new_password"] - }) do + User.update_as_admin(user, params) do ModerationLog.insert_log(%{ actor: admin, subject: [user], - action: "change_password" + action: "updated_users" }) - User.force_password_reset_async(user) + if params["password"] do + User.force_password_reset_async(user) + end ModerationLog.insert_log(%{ actor: admin, diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index 1e03849de..a16a3ebf0 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -23,6 +23,43 @@ def render("index.json", %{users: users}) do } end + def render("credentials.json", %{user: user, for: for_user}) do + user = User.sanitize_html(user, User.html_filter_policy(for_user)) + avatar = User.avatar_url(user) |> MediaProxy.url() + banner = User.banner_url(user) |> MediaProxy.url() + background = image_url(user.background) |> MediaProxy.url() + + user + |> Map.take([ + :id, + :bio, + :email, + :fields, + :name, + :nickname, + :locked, + :no_rich_text, + :default_scope, + :hide_follows, + :hide_followers_count, + :hide_follows_count, + :hide_followers, + :hide_favorites, + :allow_following_move, + :show_role, + :skip_thread_containment, + :pleroma_settings_store, + :raw_fields, + :discoverable, + :actor_type + ]) + |> Map.merge(%{ + "avatar" => avatar, + "banner" => banner, + "background" => background + }) + end + def render("show.json", %{user: user}) do avatar = User.avatar_url(user) |> MediaProxy.url() display_name = Pleroma.HTML.strip_tags(user.name || user.nickname) @@ -104,4 +141,7 @@ defp parse_error(errors) do "" end end + + defp image_url(%{"url" => [%{"href" => href} | _]}), do: href + defp image_url(_), do: nil end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 88c997b9f..56e6214c5 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -8,7 +8,6 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, truthy_param?: 1, assign_account_by_id: 2, json_response: 3] - alias Pleroma.Emoji alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.RateLimiter alias Pleroma.User @@ -140,17 +139,6 @@ def verify_credentials(%{assigns: %{user: user}} = conn, _) do def update_credentials(%{assigns: %{user: original_user}} = conn, params) do user = original_user - params = - if Map.has_key?(params, "fields_attributes") do - Map.update!(params, "fields_attributes", fn fields -> - fields - |> normalize_fields_attributes() - |> Enum.filter(fn %{"name" => n} -> n != "" end) - end) - else - params - end - user_params = [ :no_rich_text, @@ -169,46 +157,20 @@ def update_credentials(%{assigns: %{user: original_user}} = conn, params) do add_if_present(acc, params, to_string(key), key, &{:ok, truthy_param?(&1)}) end) |> add_if_present(params, "display_name", :name) - |> add_if_present(params, "note", :bio, fn value -> {:ok, User.parse_bio(value, user)} end) - |> add_if_present(params, "avatar", :avatar, fn value -> - with %Plug.Upload{} <- value, - {:ok, object} <- ActivityPub.upload(value, type: :avatar) do - {:ok, object.data} - end - end) - |> add_if_present(params, "header", :banner, fn value -> - with %Plug.Upload{} <- value, - {:ok, object} <- ActivityPub.upload(value, type: :banner) do - {:ok, object.data} - end - end) - |> add_if_present(params, "pleroma_background_image", :background, fn value -> - with %Plug.Upload{} <- value, - {:ok, object} <- ActivityPub.upload(value, type: :background) do - {:ok, object.data} - end - end) - |> add_if_present(params, "fields_attributes", :fields, fn fields -> - fields = Enum.map(fields, fn f -> Map.update!(f, "value", &AutoLinker.link(&1)) end) - - {:ok, fields} - end) - |> add_if_present(params, "fields_attributes", :raw_fields) - |> add_if_present(params, "pleroma_settings_store", :pleroma_settings_store, fn value -> - {:ok, Map.merge(user.pleroma_settings_store, value)} - end) + |> add_if_present(params, "note", :bio) + |> add_if_present(params, "avatar", :avatar) + |> add_if_present(params, "header", :banner) + |> add_if_present(params, "pleroma_background_image", :background) + |> add_if_present( + params, + "fields_attributes", + :raw_fields, + &{:ok, normalize_fields_attributes(&1)} + ) + |> add_if_present(params, "pleroma_settings_store", :pleroma_settings_store) |> add_if_present(params, "default_scope", :default_scope) |> add_if_present(params, "actor_type", :actor_type) - emojis_text = (user_params["display_name"] || "") <> (user_params["note"] || "") - - user_emojis = - user - |> Map.get(:emoji, []) - |> Enum.concat(Emoji.Formatter.get_emoji_map(emojis_text)) - |> Enum.dedup() - - user_params = Map.put(user_params, :emoji, user_emojis) changeset = User.update_changeset(user, user_params) with {:ok, user} <- User.update_and_set_cache(changeset) do diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index c03ad101e..2927775eb 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -173,7 +173,8 @@ defmodule Pleroma.Web.Router do get("/users/:nickname/password_reset", AdminAPIController, :get_password_reset) patch("/users/force_password_reset", AdminAPIController, :force_password_reset) - patch("/users/:nickname/change_password", AdminAPIController, :change_password) + get("/users/:nickname/credentials", AdminAPIController, :show_user_credentials) + patch("/users/:nickname/credentials", AdminAPIController, :update_user_credentials) get("/users", AdminAPIController, :list_users) get("/users/:nickname", AdminAPIController, :user_show) diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 0c1214f05..0a317cf88 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -3389,30 +3389,73 @@ test "returns log filtered by search", %{conn: conn, moderator: moderator} do end end - describe "PATCH /users/:nickname/change_password" do - test "changes password", %{conn: conn, admin: admin} do + describe "GET /users/:nickname/credentials" do + test "gets the user credentials", %{conn: conn} do + user = insert(:user) + conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}/credentials") + + response = assert json_response(conn, 200) + assert response["email"] == user.email + end + + test "returns 403 if requested by a non-admin" do + user = insert(:user) + + conn = + build_conn() + |> assign(:user, user) + |> get("/api/pleroma/admin/users/#{user.nickname}/credentials") + + assert json_response(conn, :forbidden) + end + end + + describe "PATCH /users/:nickname/credentials" do + test "changes password and email", %{conn: conn, admin: admin} do user = insert(:user) assert user.password_reset_pending == false conn = - patch(conn, "/api/pleroma/admin/users/#{user.nickname}/change_password", %{ - "new_password" => "password" + patch(conn, "/api/pleroma/admin/users/#{user.nickname}/credentials", %{ + "password" => "new_password", + "email" => "new_email@example.com", + "name" => "new_name" }) assert json_response(conn, 200) == %{"status" => "success"} ObanHelpers.perform_all() - assert User.get_by_id(user.id).password_reset_pending == true + updated_user = User.get_by_id(user.id) - [log_entry1, log_entry2] = ModerationLog |> Repo.all() |> Enum.sort() + assert updated_user.email == "new_email@example.com" + assert updated_user.name == "new_name" + assert updated_user.password_hash != user.password_hash + assert updated_user.password_reset_pending == true + + [log_entry2, log_entry1] = ModerationLog |> Repo.all() |> Enum.sort() assert ModerationLog.get_log_entry_message(log_entry1) == - "@#{admin.nickname} changed password for users: @#{user.nickname}" + "@#{admin.nickname} updated users: @#{user.nickname}" assert ModerationLog.get_log_entry_message(log_entry2) == "@#{admin.nickname} forced password reset for users: @#{user.nickname}" end + + test "returns 403 if requested by a non-admin" do + user = insert(:user) + + conn = + build_conn() + |> assign(:user, user) + |> patch("/api/pleroma/admin/users/#{user.nickname}/credentials", %{ + "password" => "new_password", + "email" => "new_email@example.com", + "name" => "new_name" + }) + + assert json_response(conn, :forbidden) + end end describe "PATCH /users/:nickname/force_password_reset" do From 74388336852b18d5d5f108a8305f1a038301f7a1 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Mon, 16 Mar 2020 21:58:10 +0300 Subject: [PATCH 088/188] [#1364] Improved notification-related tests. --- lib/pleroma/notification.ex | 1 + test/notification_test.exs | 121 +++++++++++++++++++++++------------- 2 files changed, 79 insertions(+), 43 deletions(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 0d7a6610a..104368fd1 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -344,6 +344,7 @@ def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, lo |> Utils.maybe_notify_followers(activity) |> Enum.uniq() + # Since even subscribers and followers can mute / thread-mute, filtering all above AP IDs notification_enabled_ap_ids = potential_receiver_ap_ids |> exclude_relation_restricting_ap_ids(activity) diff --git a/test/notification_test.exs b/test/notification_test.exs index bc2d80f05..a7282c929 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -82,6 +82,80 @@ test "does not create a notification for subscribed users if status is a reply" end end + describe "CommonApi.post/2 notification-related functionality" do + test_with_mock "creates but does NOT send notification to blocker user", + Push, + [:passthrough], + [] do + user = insert(:user) + blocker = insert(:user) + {:ok, _user_relationship} = User.block(blocker, user) + + {:ok, _activity} = CommonAPI.post(user, %{"status" => "hey @#{blocker.nickname}!"}) + + blocker_id = blocker.id + assert [%Notification{user_id: ^blocker_id}] = Repo.all(Notification) + refute called(Push.send(:_)) + end + + test_with_mock "creates but does NOT send notification to notification-muter user", + Push, + [:passthrough], + [] do + user = insert(:user) + muter = insert(:user) + {:ok, _user_relationships} = User.mute(muter, user) + + {:ok, _activity} = CommonAPI.post(user, %{"status" => "hey @#{muter.nickname}!"}) + + muter_id = muter.id + assert [%Notification{user_id: ^muter_id}] = Repo.all(Notification) + refute called(Push.send(:_)) + end + + test_with_mock "creates but does NOT send notification to thread-muter user", + Push, + [:passthrough], + [] do + user = insert(:user) + thread_muter = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{thread_muter.nickname}!"}) + + {:ok, _} = CommonAPI.add_mute(thread_muter, activity) + + {:ok, _same_context_activity} = + CommonAPI.post(user, %{ + "status" => "hey-hey-hey @#{thread_muter.nickname}!", + "in_reply_to_status_id" => activity.id + }) + + [pre_mute_notification, post_mute_notification] = + Repo.all(from(n in Notification, where: n.user_id == ^thread_muter.id, order_by: n.id)) + + pre_mute_notification_id = pre_mute_notification.id + post_mute_notification_id = post_mute_notification.id + + assert called( + Push.send( + :meck.is(fn + %Notification{id: ^pre_mute_notification_id} -> true + _ -> false + end) + ) + ) + + refute called( + Push.send( + :meck.is(fn + %Notification{id: ^post_mute_notification_id} -> true + _ -> false + end) + ) + ) + end + end + describe "create_notification" do @tag needs_streamer: true test "it creates a notification for user and send to the 'user' and the 'user:notification' stream" do @@ -489,10 +563,7 @@ test "it does not send notification to mentioned users in announces" do assert other_user not in enabled_receivers end - test_with_mock "it returns blocking recipient in disabled recipients list", - Push, - [:passthrough], - [] do + test "it returns blocking recipient in disabled recipients list" do user = insert(:user) other_user = insert(:user) {:ok, _user_relationship} = User.block(other_user, user) @@ -503,15 +574,9 @@ test "it does not send notification to mentioned users in announces" do assert [] == enabled_receivers assert [other_user] == disabled_receivers - - assert 1 == length(Repo.all(Notification)) - refute called(Push.send(:_)) end - test_with_mock "it returns notification-muting recipient in disabled recipients list", - Push, - [:passthrough], - [] do + test "it returns notification-muting recipient in disabled recipients list" do user = insert(:user) other_user = insert(:user) {:ok, _user_relationships} = User.mute(other_user, user) @@ -522,15 +587,9 @@ test "it does not send notification to mentioned users in announces" do assert [] == enabled_receivers assert [other_user] == disabled_receivers - - assert 1 == length(Repo.all(Notification)) - refute called(Push.send(:_)) end - test_with_mock "it returns thread-muting recipient in disabled recipients list", - Push, - [:passthrough], - [] do + test "it returns thread-muting recipient in disabled recipients list" do user = insert(:user) other_user = insert(:user) @@ -549,30 +608,6 @@ test "it does not send notification to mentioned users in announces" do assert [other_user] == disabled_receivers refute other_user in enabled_receivers - - [pre_mute_notification, post_mute_notification] = - Repo.all(from(n in Notification, where: n.user_id == ^other_user.id, order_by: n.id)) - - pre_mute_notification_id = pre_mute_notification.id - post_mute_notification_id = post_mute_notification.id - - assert called( - Push.send( - :meck.is(fn - %Notification{id: ^pre_mute_notification_id} -> true - _ -> false - end) - ) - ) - - refute called( - Push.send( - :meck.is(fn - %Notification{id: ^post_mute_notification_id} -> true - _ -> false - end) - ) - ) end end @@ -820,7 +855,7 @@ test "it doesn't return notifications for blocked user" do assert Notification.for_user(user) == [] end - test "it doesn't return notificatitons for blocked domain" do + test "it doesn't return notifications for blocked domain" do user = insert(:user) blocked = insert(:user, ap_id: "http://some-domain.com") {:ok, user} = User.block_domain(user, "some-domain.com") From b17d8d305f5e9bf25644fd9b3457a965e3a5c001 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 16 Mar 2020 15:39:34 -0500 Subject: [PATCH 089/188] Enable Gun adapter by default We need devs to dogfood this before we merge it into the 2.1 release --- config/config.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/config.exs b/config/config.exs index 3ec1868b2..154eda48a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -170,7 +170,7 @@ "application/ld+json" => ["activity+json"] } -config :tesla, adapter: Tesla.Adapter.Hackney +config :tesla, adapter: Tesla.Adapter.Gun # Configures http settings, upstream proxy etc. config :pleroma, :http, proxy_url: nil, From 4705590f76f6a875aa99f32c8b08c20d793470a8 Mon Sep 17 00:00:00 2001 From: Cevado Date: Mon, 16 Mar 2020 22:02:01 -0300 Subject: [PATCH 090/188] Fix ssl option on Ecto config breaking release To use `:ssl` option on Ecto config it's required to include Erlang ssl application, this prevents releases to start when `:ssl` option is set to true. --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index dd598345c..890979f8b 100644 --- a/mix.exs +++ b/mix.exs @@ -63,7 +63,7 @@ def copy_nginx_config(%{path: target_path} = release) do def application do [ mod: {Pleroma.Application, []}, - extra_applications: [:logger, :runtime_tools, :comeonin, :quack, :fast_sanitize], + extra_applications: [:logger, :runtime_tools, :comeonin, :quack, :fast_sanitize, :ssl], included_applications: [:ex_syslogger] ] end From d3cf7e19fbe089b3a6d62d6a26f3dfc866a6669d Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 17 Mar 2020 13:02:10 +0100 Subject: [PATCH 091/188] activity_pub_controller_test.exs: test posting with AP C2S uploaded media --- .../activity_pub_controller_test.exs | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index bd8e0b5cc..2bd494a37 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -1241,16 +1241,46 @@ test "POST /api/ap/upload_media", %{conn: conn} do filename: "an_image.jpg" } - conn = + object = conn |> assign(:user, user) |> post("/api/ap/upload_media", %{"file" => image, "description" => desc}) + |> json_response(:created) - assert object = json_response(conn, :created) assert object["name"] == desc assert object["type"] == "Document" assert object["actor"] == user.ap_id + assert [%{"href" => object_href}] = object["url"] + activity_request = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "type" => "Create", + "object" => %{ + "type" => "Note", + "content" => "AP C2S test, attachment", + "attachment" => [object] + }, + "to" => "https://www.w3.org/ns/activitystreams#Public", + "cc" => [] + } + + activity_response = + conn + |> assign(:user, user) + |> post("/users/#{user.nickname}/outbox", activity_request) + |> json_response(:created) + + assert activity_response["id"] + assert activity_response["object"] + assert activity_response["actor"] == user.ap_id + + assert %Object{data: %{"attachment" => [attachment]}} = Object.normalize(activity_response["object"]) + assert attachment["type"] == "Document" + assert attachment["name"] == desc + assert [%{"href" => attachment_href}] = attachment["url"] + assert attachment_href == object_href + + # Fails if unauthenticated conn |> post("/api/ap/upload_media", %{"file" => image, "description" => desc}) |> json_response(403) From f9d622d25a744f58fbaf8370ad4435597bb15bf0 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 19 Mar 2020 15:08:49 +0100 Subject: [PATCH 092/188] WIP --- lib/pleroma/web/activity_pub/transmogrifier.ex | 15 --------------- .../activity_pub_controller_test.exs | 18 ++++++++++++++---- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 9cd3de705..db848f657 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -202,21 +202,6 @@ def fix_context(object) do |> Map.put("conversation", context) end - def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do - attachments = - Enum.map(attachment, fn data -> - media_type = data["mediaType"] || data["mimeType"] - href = data["url"] || data["href"] - url = [%{"type" => "Link", "mediaType" => media_type, "href" => href}] - - data - |> Map.put("mediaType", media_type) - |> Map.put("url", url) - end) - - Map.put(object, "attachment", attachments) - end - def fix_attachments(%{"attachment" => attachment} = object) when is_map(attachment) do object |> Map.put("attachment", [attachment]) diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index 2bd494a37..01c955c0a 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -1250,7 +1250,9 @@ test "POST /api/ap/upload_media", %{conn: conn} do assert object["name"] == desc assert object["type"] == "Document" assert object["actor"] == user.ap_id - assert [%{"href" => object_href}] = object["url"] + assert [%{"href" => object_href, "mediaType" => object_mediatype}] = object["url"] + assert is_binary(object_href) + assert object_mediatype == "image/jpeg" activity_request = %{ "@context" => "https://www.w3.org/ns/activitystreams", @@ -1274,11 +1276,19 @@ test "POST /api/ap/upload_media", %{conn: conn} do assert activity_response["object"] assert activity_response["actor"] == user.ap_id - assert %Object{data: %{"attachment" => [attachment]}} = Object.normalize(activity_response["object"]) + assert %Object{data: %{"attachment" => [attachment]}} = + Object.normalize(activity_response["object"]) + assert attachment["type"] == "Document" assert attachment["name"] == desc - assert [%{"href" => attachment_href}] = attachment["url"] - assert attachment_href == object_href + + assert [ + %{ + "href" => ^object_href, + "type" => "Link", + "mediaType" => ^object_mediatype + } + ] = attachment["url"] # Fails if unauthenticated conn From 7d275970ab191af539acbc0baec3bc1d0a2558e1 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 19 Mar 2020 10:08:11 -0500 Subject: [PATCH 093/188] Add emoji reactions to features in nodeinfo --- lib/pleroma/web/nodeinfo/nodeinfo_controller.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex index 18eb41333..c653a80c3 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex @@ -74,7 +74,8 @@ def raw_nodeinfo do end, if Config.get([:instance, :safe_dm_mentions]) do "safe_dm_mentions" - end + end, + "pleroma_emoji_reactions" ] |> Enum.filter(& &1) From 9b9d67bbec537df6f7c5729e81da6deeaf896bd9 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 19 Mar 2020 18:16:12 +0100 Subject: [PATCH 094/188] Fix linting. --- .../web/activity_pub/object_validators/create_validator.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/create_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_validator.ex index bd90f7250..9e480c4ed 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_validator.ex @@ -5,8 +5,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateNoteValidator do use Ecto.Schema - alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset From c1fd4f665335ba67336bd1b2fab2d9df5e247e08 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 19 Mar 2020 19:10:03 +0100 Subject: [PATCH 095/188] transmogrifier.ex: rework fix_attachment for better IR --- .../web/activity_pub/transmogrifier.ex | 45 +++++++++++++++++++ test/web/activity_pub/transmogrifier_test.exs | 30 +++---------- 2 files changed, 50 insertions(+), 25 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index db848f657..df5ca0239 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -202,6 +202,51 @@ def fix_context(object) do |> Map.put("conversation", context) end + defp add_if_present(map, _key, nil), do: map + + defp add_if_present(map, key, value) do + Map.put(map, key, value) + end + + def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do + attachments = + Enum.map(attachment, fn data -> + url = + cond do + is_list(data["url"]) -> List.first(data["url"]) + is_map(data["url"]) -> data["url"] + true -> nil + end + + media_type = + cond do + is_map(url) && is_binary(url["mediaType"]) -> url["mediaType"] + is_binary(data["mediaType"]) -> data["mediaType"] + is_binary(data["mimeType"]) -> data["mimeType"] + true -> nil + end + + href = + cond do + is_map(url) && is_binary(url["href"]) -> url["href"] + is_binary(data["url"]) -> data["url"] + is_binary(data["href"]) -> data["href"] + end + + attachment_url = + %{"href" => href} + |> add_if_present("mediaType", media_type) + |> add_if_present("type", Map.get(url || %{}, "type")) + + %{"url" => [attachment_url]} + |> add_if_present("mediaType", media_type) + |> add_if_present("type", data["type"]) + |> add_if_present("name", data["name"]) + end) + + Map.put(object, "attachment", attachments) + end + def fix_attachments(%{"attachment" => attachment} = object) when is_map(attachment) do object |> Map.put("attachment", [attachment]) diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index efbca82f6..242d933e7 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -1228,19 +1228,13 @@ test "it remaps video URLs as attachments if necessary" do attachment = %{ "type" => "Link", "mediaType" => "video/mp4", - "href" => - "https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4", - "mimeType" => "video/mp4", - "size" => 5_015_880, "url" => [ %{ "href" => "https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4", - "mediaType" => "video/mp4", - "type" => "Link" + "mediaType" => "video/mp4" } - ], - "width" => 480 + ] } assert object.data["url"] == @@ -2067,11 +2061,7 @@ test "returns modified object when attachment is map" do %{ "mediaType" => "video/mp4", "url" => [ - %{ - "href" => "https://peertube.moe/stat-480.mp4", - "mediaType" => "video/mp4", - "type" => "Link" - } + %{"href" => "https://peertube.moe/stat-480.mp4", "mediaType" => "video/mp4"} ] } ] @@ -2089,23 +2079,13 @@ test "returns modified object when attachment is list" do %{ "mediaType" => "video/mp4", "url" => [ - %{ - "href" => "https://pe.er/stat-480.mp4", - "mediaType" => "video/mp4", - "type" => "Link" - } + %{"href" => "https://pe.er/stat-480.mp4", "mediaType" => "video/mp4"} ] }, %{ - "href" => "https://pe.er/stat-480.mp4", "mediaType" => "video/mp4", - "mimeType" => "video/mp4", "url" => [ - %{ - "href" => "https://pe.er/stat-480.mp4", - "mediaType" => "video/mp4", - "type" => "Link" - } + %{"href" => "https://pe.er/stat-480.mp4", "mediaType" => "video/mp4"} ] } ] From 6c1232b486dcad5a644b4292697d08ebe3000cb3 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 20 Mar 2020 15:00:28 +0100 Subject: [PATCH 096/188] NotificationController: Fix test. --- .../mastodon_api/controllers/notification_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/mastodon_api/controllers/notification_controller_test.exs b/test/web/mastodon_api/controllers/notification_controller_test.exs index e407b8297..adbb78da6 100644 --- a/test/web/mastodon_api/controllers/notification_controller_test.exs +++ b/test/web/mastodon_api/controllers/notification_controller_test.exs @@ -310,7 +310,7 @@ test "filters notifications using include_types" do {:ok, mention_activity} = CommonAPI.post(other_user, %{"status" => "hey @#{user.nickname}"}) {:ok, create_activity} = CommonAPI.post(user, %{"status" => "hey"}) - {:ok, favorite_activity, _} = CommonAPI.favorite(create_activity.id, other_user) + {:ok, favorite_activity} = CommonAPI.favorite(other_user, create_activity.id) {:ok, reblog_activity, _} = CommonAPI.repeat(create_activity.id, other_user) {:ok, _, _, follow_activity} = CommonAPI.follow(other_user, user) From 7f9b5284fa7dd1d9100de730a6fe0c93739d1b30 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 20 Mar 2020 20:58:47 +0300 Subject: [PATCH 097/188] updating clear_config --- test/http/adapter_helper/gun_test.exs | 4 +--- test/http/adapter_helper/hackney_test.exs | 5 +---- test/http/connection_test.exs | 2 +- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/test/http/adapter_helper/gun_test.exs b/test/http/adapter_helper/gun_test.exs index 18025b986..2e961826e 100644 --- a/test/http/adapter_helper/gun_test.exs +++ b/test/http/adapter_helper/gun_test.exs @@ -28,9 +28,7 @@ defp gun_mock do end describe "options/1" do - clear_config([:http, :adapter]) do - Config.put([:http, :adapter], a: 1, b: 2) - end + setup do: clear_config([:http, :adapter], a: 1, b: 2) test "https url with default port" do uri = URI.parse("https://example.com") diff --git a/test/http/adapter_helper/hackney_test.exs b/test/http/adapter_helper/hackney_test.exs index 5fda075f6..3f7e708e0 100644 --- a/test/http/adapter_helper/hackney_test.exs +++ b/test/http/adapter_helper/hackney_test.exs @@ -6,7 +6,6 @@ defmodule Pleroma.HTTP.AdapterHelper.HackneyTest do use ExUnit.Case, async: true use Pleroma.Tests.Helpers - alias Pleroma.Config alias Pleroma.HTTP.AdapterHelper.Hackney setup_all do @@ -15,9 +14,7 @@ defmodule Pleroma.HTTP.AdapterHelper.HackneyTest do end describe "options/2" do - clear_config([:http, :adapter]) do - Config.put([:http, :adapter], a: 1, b: 2) - end + setup do: clear_config([:http, :adapter], a: 1, b: 2) test "add proxy and opts from config", %{uri: uri} do opts = Hackney.options([proxy: "localhost:8123"], uri) diff --git a/test/http/connection_test.exs b/test/http/connection_test.exs index 0f62eddd2..5cc78ad5b 100644 --- a/test/http/connection_test.exs +++ b/test/http/connection_test.exs @@ -82,7 +82,7 @@ test "with nil" do end describe "options/3" do - clear_config([:http, :proxy_url]) + setup do: clear_config([:http, :proxy_url]) test "without proxy_url in config" do Config.delete([:http, :proxy_url]) From 981e015f1b68c7cf807b0ddbf3948809f11b7fff Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sun, 22 Mar 2020 17:10:37 +0300 Subject: [PATCH 098/188] Mastodon API Account view: Remove an outdated hack The hack with caching the follow relationship was introduced when we still were storing it inside the follow activity, resulting in slow queries. Now we store follow state in `FollowRelationship` table, so this is no longer necessary. --- lib/pleroma/user.ex | 18 ------------------ lib/pleroma/web/activity_pub/activity_pub.ex | 3 +-- lib/pleroma/web/activity_pub/utils.ex | 5 +---- .../web/mastodon_api/views/account_view.ex | 13 +++---------- 4 files changed, 5 insertions(+), 34 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 8693c0b80..12c2ad815 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -292,24 +292,6 @@ def ap_followers(%User{} = user), do: "#{ap_id(user)}/followers" def ap_following(%User{following_address: fa}) when is_binary(fa), do: fa def ap_following(%User{} = user), do: "#{ap_id(user)}/following" - def follow_state(%User{} = user, %User{} = target) do - case Utils.fetch_latest_follow(user, target) do - %{data: %{"state" => state}} -> state - # Ideally this would be nil, but then Cachex does not commit the value - _ -> false - end - end - - def get_cached_follow_state(user, target) do - key = "follow_state:#{user.ap_id}|#{target.ap_id}" - Cachex.fetch!(:user_cache, key, fn _ -> {:commit, follow_state(user, target)} end) - end - - @spec set_follow_state_cache(String.t(), String.t(), String.t()) :: {:ok | :error, boolean()} - def set_follow_state_cache(user_ap_id, target_ap_id, state) do - Cachex.put(:user_cache, "follow_state:#{user_ap_id}|#{target_ap_id}", state) - end - @spec restrict_deactivated(Ecto.Query.t()) :: Ecto.Query.t() def restrict_deactivated(query) do from(u in query, where: u.deactivated != ^true) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index d9f74b6a4..30e282840 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -503,8 +503,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), - :ok <- maybe_federate(activity), - _ <- User.set_follow_state_cache(follower.ap_id, followed.ap_id, activity.data["state"]) do + :ok <- maybe_federate(activity) do {:ok, activity} else {:error, error} -> Repo.rollback(error) diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index 15dd2ed45..c65bbed67 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -440,22 +440,19 @@ def update_follow_state_for_all( |> update(set: [data: fragment("jsonb_set(data, '{state}', ?)", ^state)]) |> Repo.update_all([]) - User.set_follow_state_cache(actor, object, state) - activity = Activity.get_by_id(activity.id) {:ok, activity} end def update_follow_state( - %Activity{data: %{"actor" => actor, "object" => object}} = activity, + %Activity{} = activity, state ) do new_data = Map.put(activity.data, "state", state) changeset = Changeset.change(activity, data: new_data) with {:ok, activity} <- Repo.update(changeset) do - User.set_follow_state_cache(actor, object, state) {:ok, activity} end end diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 341dc2c91..4ebce73b4 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -36,25 +36,18 @@ def render("relationship.json", %{user: nil, target: _target}) do end def render("relationship.json", %{user: %User{} = user, target: %User{} = target}) do - follow_state = User.get_cached_follow_state(user, target) - - requested = - if follow_state && !User.following?(user, target) do - follow_state == "pending" - else - false - end + follow_state = User.get_follow_state(user, target) %{ id: to_string(target.id), - following: User.following?(user, target), + following: follow_state == "accept", followed_by: User.following?(target, user), blocking: User.blocks_user?(user, target), blocked_by: User.blocks_user?(target, user), muting: User.mutes?(user, target), muting_notifications: User.muted_notifications?(user, target), subscribing: User.subscribed_to?(user, target), - requested: requested, + requested: follow_state == "pending", domain_blocking: User.blocks_domain?(user, target), showing_reblogs: User.showing_reblogs?(user, target), endorsed: false From 15be6ba9c200b2a4ae153d26876be1b5cbb6357e Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 22 Mar 2020 16:38:12 +0100 Subject: [PATCH 099/188] AccountView: fix for other forms of
in bio Closes: https://git.pleroma.social/pleroma/pleroma/issues/1643 --- lib/pleroma/web/mastodon_api/views/account_view.ex | 2 +- .../account_controller/update_credentials_test.exs | 4 ++-- test/web/mastodon_api/views/account_view_test.exs | 7 ++++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 4ebce73b4..2bf711386 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -115,7 +115,7 @@ defp do_render("show.json", %{user: user} = opts) do fields: user.fields, bot: bot, source: %{ - note: Pleroma.HTML.strip_tags((user.bio || "") |> String.replace("
", "\n")), + note: (user.bio || "") |> String.replace(~r(
), "\n") |> Pleroma.HTML.strip_tags(), sensitive: false, fields: user.raw_fields, pleroma: %{ diff --git a/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs index 43538cb17..51cebe567 100644 --- a/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs +++ b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs @@ -76,7 +76,7 @@ test "updates the user's bio", %{conn: conn} do conn = patch(conn, "/api/v1/accounts/update_credentials", %{ - "note" => "I drink #cofe with @#{user2.nickname}" + "note" => "I drink #cofe with @#{user2.nickname}\n\nsuya.." }) assert user_data = json_response(conn, 200) @@ -84,7 +84,7 @@ test "updates the user's bio", %{conn: conn} do assert user_data["note"] == ~s(I drink #cofe with @#{user2.nickname}) + }" class="u-url mention" href="#{user2.ap_id}" rel="ugc">@#{user2.nickname}

suya..) end test "updates the user's locking status", %{conn: conn} do diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index d60ed7b64..983886c6b 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -32,7 +32,8 @@ test "Represent a user account" do background: background_image, nickname: "shp@shitposter.club", name: ":karjalanpiirakka: shp", - bio: "valid html", + bio: + "valid html. a
b
c
d
f", inserted_at: ~N[2017-08-15 15:47:06.597036] }) @@ -46,7 +47,7 @@ test "Represent a user account" do followers_count: 3, following_count: 0, statuses_count: 5, - note: "valid html", + note: "valid html. a
b
c
d
f", url: user.ap_id, avatar: "http://localhost:4001/images/avi.png", avatar_static: "http://localhost:4001/images/avi.png", @@ -63,7 +64,7 @@ test "Represent a user account" do fields: [], bot: false, source: %{ - note: "valid html", + note: "valid html. a\nb\nc\nd\nf", sensitive: false, pleroma: %{ actor_type: "Person", From c2e415143b1dfe5d89eff06fbce6840c445aa5fa Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sun, 22 Mar 2020 21:51:44 +0300 Subject: [PATCH 100/188] WIP: preloading of user relations for timeline/statuses rendering (performance improvement). --- lib/pleroma/user.ex | 6 +- lib/pleroma/user_relationship.ex | 44 ++++++++++++ .../web/mastodon_api/views/account_view.ex | 69 ++++++++++++++++--- .../web/mastodon_api/views/status_view.ex | 60 ++++++++++++++-- 4 files changed, 160 insertions(+), 19 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 12c2ad815..daaa6d86b 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1642,8 +1642,12 @@ def all_superusers do |> Repo.all() end + def muting_reblogs?(%User{} = user, %User{} = target) do + UserRelationship.reblog_mute_exists?(user, target) + end + def showing_reblogs?(%User{} = user, %User{} = target) do - not UserRelationship.reblog_mute_exists?(user, target) + not muting_reblogs?(user, target) end @doc """ diff --git a/lib/pleroma/user_relationship.ex b/lib/pleroma/user_relationship.ex index 393947942..167a3919c 100644 --- a/lib/pleroma/user_relationship.ex +++ b/lib/pleroma/user_relationship.ex @@ -8,6 +8,7 @@ defmodule Pleroma.UserRelationship do import Ecto.Changeset import Ecto.Query + alias FlakeId.Ecto.CompatType alias Pleroma.Repo alias Pleroma.User alias Pleroma.UserRelationship @@ -34,6 +35,10 @@ def unquote(:"#{relationship_type}_exists?")(source, target), do: exists?(unquote(relationship_type), source, target) end + def user_relationship_types, do: Keyword.keys(user_relationship_mappings()) + + def user_relationship_mappings, do: UserRelationshipTypeEnum.__enum_map__() + def changeset(%UserRelationship{} = user_relationship, params \\ %{}) do user_relationship |> cast(params, [:relationship_type, :source_id, :target_id]) @@ -72,6 +77,45 @@ def delete(relationship_type, %User{} = source, %User{} = target) do end end + def dictionary( + source_users, + target_users, + source_to_target_rel_types \\ nil, + target_to_source_rel_types \\ nil + ) + when is_list(source_users) and is_list(target_users) do + get_bin_ids = fn user -> + with {:ok, bin_id} <- CompatType.dump(user.id), do: bin_id + end + + source_user_ids = Enum.map(source_users, &get_bin_ids.(&1)) + target_user_ids = Enum.map(target_users, &get_bin_ids.(&1)) + + get_rel_type_codes = fn rel_type -> user_relationship_mappings()[rel_type] end + + source_to_target_rel_types = + Enum.map(source_to_target_rel_types || user_relationship_types(), &get_rel_type_codes.(&1)) + + target_to_source_rel_types = + Enum.map(target_to_source_rel_types || user_relationship_types(), &get_rel_type_codes.(&1)) + + __MODULE__ + |> where( + fragment( + "(source_id = ANY(?) AND target_id = ANY(?) AND relationship_type = ANY(?)) OR \ + (source_id = ANY(?) AND target_id = ANY(?) AND relationship_type = ANY(?))", + ^source_user_ids, + ^target_user_ids, + ^source_to_target_rel_types, + ^target_user_ids, + ^source_user_ids, + ^target_to_source_rel_types + ) + ) + |> select([ur], [ur.relationship_type, ur.source_id, ur.target_id]) + |> Repo.all() + end + defp validate_not_self_relationship(%Ecto.Changeset{} = changeset) do changeset |> validate_change(:target_id, fn _, target_id -> diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 4ebce73b4..15a579278 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -10,6 +10,19 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MediaProxy + def test_rel(user_relationships, rel_type, source, target, func) do + cond do + is_nil(source) or is_nil(target) -> + false + + user_relationships -> + [rel_type, source.id, target.id] in user_relationships + + true -> + func.(source, target) + end + end + def render("index.json", %{users: users} = opts) do users |> render_many(AccountView, "show.json", opts) @@ -35,21 +48,50 @@ def render("relationship.json", %{user: nil, target: _target}) do %{} end - def render("relationship.json", %{user: %User{} = user, target: %User{} = target}) do - follow_state = User.get_follow_state(user, target) + def render( + "relationship.json", + %{user: %User{} = reading_user, target: %User{} = target} = opts + ) do + user_relationships = Map.get(opts, :user_relationships) + follow_state = User.get_follow_state(reading_user, target) + + # TODO: add a note on adjusting StatusView.user_relationships_opt/1 re: preloading of user relations %{ id: to_string(target.id), following: follow_state == "accept", - followed_by: User.following?(target, user), - blocking: User.blocks_user?(user, target), - blocked_by: User.blocks_user?(target, user), - muting: User.mutes?(user, target), - muting_notifications: User.muted_notifications?(user, target), - subscribing: User.subscribed_to?(user, target), + followed_by: User.following?(target, reading_user), + blocking: + test_rel(user_relationships, :block, reading_user, target, &User.blocks_user?(&1, &2)), + blocked_by: + test_rel(user_relationships, :block, target, reading_user, &User.blocks_user?(&1, &2)), + muting: test_rel(user_relationships, :mute, reading_user, target, &User.mutes?(&1, &2)), + muting_notifications: + test_rel( + user_relationships, + :notification_mute, + reading_user, + target, + &User.muted_notifications?(&1, &2) + ), + subscribing: + test_rel( + user_relationships, + :inverse_subscription, + target, + reading_user, + &User.subscribed_to?(&2, &1) + ), requested: follow_state == "pending", - domain_blocking: User.blocks_domain?(user, target), - showing_reblogs: User.showing_reblogs?(user, target), + domain_blocking: User.blocks_domain?(reading_user, target), + showing_reblogs: + not test_rel( + user_relationships, + :reblog_mute, + reading_user, + target, + &User.muting_reblogs?(&1, &2) + ), endorsed: false } end @@ -93,7 +135,12 @@ defp do_render("show.json", %{user: user} = opts) do } end) - relationship = render("relationship.json", %{user: opts[:for], target: user}) + relationship = + render("relationship.json", %{ + user: opts[:for], + target: user, + user_relationships: opts[:user_relationships] + }) %{ id: to_string(user.id), diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index f7469cdff..e0c368ec9 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -13,6 +13,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User + alias Pleroma.UserRelationship alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView @@ -70,11 +71,34 @@ defp reblogged?(activity, user) do present?(user && user.ap_id in (object.data["announcements"] || [])) end - def render("index.json", opts) do - replied_to_activities = get_replied_to_activities(opts.activities) - opts = Map.put(opts, :replied_to_activities, replied_to_activities) + defp user_relationships_opt(opts) do + reading_user = opts[:for] - safe_render_many(opts.activities, StatusView, "show.json", opts) + if reading_user do + activities = opts[:activities] + actors = Enum.map(activities, fn a -> get_user(a.data["actor"]) end) + + UserRelationship.dictionary( + [reading_user], + actors, + [:block, :mute, :notification_mute, :reblog_mute], + [:block, :inverse_subscription] + ) + else + [] + end + end + + def render("index.json", opts) do + activities = opts.activities + replied_to_activities = get_replied_to_activities(activities) + + opts = + opts + |> Map.put(:replied_to_activities, replied_to_activities) + |> Map.put(:user_relationships, user_relationships_opt(opts)) + + safe_render_many(activities, StatusView, "show.json", opts) end def render( @@ -107,7 +131,12 @@ def render( id: to_string(activity.id), uri: activity_object.data["id"], url: activity_object.data["id"], - account: AccountView.render("show.json", %{user: user, for: opts[:for]}), + account: + AccountView.render("show.json", %{ + user: user, + for: opts[:for], + user_relationships: opts[:user_relationships] + }), in_reply_to_id: nil, in_reply_to_account_id: nil, reblog: reblogged, @@ -253,11 +282,28 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} _ -> [] end + user_relationships_opt = opts[:user_relationships] + + muted = + thread_muted? || + Pleroma.Web.MastodonAPI.AccountView.test_rel( + user_relationships_opt, + :mute, + opts[:for], + user, + fn for_user, user -> User.mutes?(for_user, user) end + ) + %{ id: to_string(activity.id), uri: object.data["id"], url: url, - account: AccountView.render("show.json", %{user: user, for: opts[:for]}), + account: + AccountView.render("show.json", %{ + user: user, + for: opts[:for], + user_relationships: user_relationships_opt + }), in_reply_to_id: reply_to && to_string(reply_to.id), in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id), reblog: nil, @@ -270,7 +316,7 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} reblogged: reblogged?(activity, opts[:for]), favourited: present?(favorited), bookmarked: present?(bookmarked), - muted: thread_muted? || User.mutes?(opts[:for], user), + muted: muted, pinned: pinned?(activity, user), sensitive: sensitive, spoiler_text: summary, From 3c78e5f3275494b3dc4546e65f19eb3a3c97033a Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Mon, 23 Mar 2020 12:01:11 +0300 Subject: [PATCH 101/188] Preloading of follow relations for timeline/statuses rendering (performance improvement). Refactoring. --- lib/pleroma/following_relationship.ex | 26 +++++++ lib/pleroma/user.ex | 7 ++ lib/pleroma/user_relationship.ex | 13 ++++ .../web/mastodon_api/views/account_view.ex | 75 ++++++++++++++----- .../web/mastodon_api/views/status_view.ex | 46 +++++++----- 5 files changed, 130 insertions(+), 37 deletions(-) diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index a6d281151..dd1696136 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -129,4 +129,30 @@ def move_following(origin, target) do move_following(origin, target) end end + + def all_between_user_sets( + source_users, + target_users + ) + when is_list(source_users) and is_list(target_users) do + get_bin_ids = fn user -> + with {:ok, bin_id} <- CompatType.dump(user.id), do: bin_id + end + + source_user_ids = Enum.map(source_users, &get_bin_ids.(&1)) + target_user_ids = Enum.map(target_users, &get_bin_ids.(&1)) + + __MODULE__ + |> where( + fragment( + "(follower_id = ANY(?) AND following_id = ANY(?)) OR \ + (follower_id = ANY(?) AND following_id = ANY(?))", + ^source_user_ids, + ^target_user_ids, + ^target_user_ids, + ^source_user_ids + ) + ) + |> Repo.all() + end end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index daaa6d86b..eb72755a0 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -674,7 +674,14 @@ def unfollow(%User{} = follower, %User{} = followed) do def get_follow_state(%User{} = follower, %User{} = following) do following_relationship = FollowingRelationship.get(follower, following) + get_follow_state(follower, following, following_relationship) + end + def get_follow_state( + %User{} = follower, + %User{} = following, + following_relationship + ) do case {following_relationship, following.local} do {nil, false} -> case Utils.fetch_latest_follow(follower, following) do diff --git a/lib/pleroma/user_relationship.ex b/lib/pleroma/user_relationship.ex index 167a3919c..9423e3a42 100644 --- a/lib/pleroma/user_relationship.ex +++ b/lib/pleroma/user_relationship.ex @@ -116,6 +116,19 @@ def dictionary( |> Repo.all() end + def exists?(dictionary, rel_type, source, target, func) do + cond do + is_nil(source) or is_nil(target) -> + false + + dictionary -> + [rel_type, source.id, target.id] in dictionary + + true -> + func.(source, target) + end + end + defp validate_not_self_relationship(%Ecto.Changeset{} = changeset) do changeset |> validate_change(:target_id, fn _, target_id -> diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 15a579278..2fe46158b 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -6,21 +6,15 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do use Pleroma.Web, :view alias Pleroma.User + alias Pleroma.UserRelationship alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MediaProxy - def test_rel(user_relationships, rel_type, source, target, func) do - cond do - is_nil(source) or is_nil(target) -> - false - - user_relationships -> - [rel_type, source.id, target.id] in user_relationships - - true -> - func.(source, target) - end + defp find_following_rel(following_relationships, follower, following) do + Enum.find(following_relationships, fn + fr -> fr.follower_id == follower.id and fr.following_id == following.id + end) end def render("index.json", %{users: users} = opts) do @@ -53,21 +47,61 @@ def render( %{user: %User{} = reading_user, target: %User{} = target} = opts ) do user_relationships = Map.get(opts, :user_relationships) + following_relationships = opts[:following_relationships] - follow_state = User.get_follow_state(reading_user, target) + follow_state = + if following_relationships do + user_to_target_following_relation = + find_following_rel(following_relationships, reading_user, target) + + User.get_follow_state(reading_user, target, user_to_target_following_relation) + else + User.get_follow_state(reading_user, target) + end + + followed_by = + if following_relationships do + with %{state: "accept"} <- + find_following_rel(following_relationships, target, reading_user) do + true + else + _ -> false + end + else + User.following?(target, reading_user) + end # TODO: add a note on adjusting StatusView.user_relationships_opt/1 re: preloading of user relations %{ id: to_string(target.id), following: follow_state == "accept", - followed_by: User.following?(target, reading_user), + followed_by: followed_by, blocking: - test_rel(user_relationships, :block, reading_user, target, &User.blocks_user?(&1, &2)), + UserRelationship.exists?( + user_relationships, + :block, + reading_user, + target, + &User.blocks_user?(&1, &2) + ), blocked_by: - test_rel(user_relationships, :block, target, reading_user, &User.blocks_user?(&1, &2)), - muting: test_rel(user_relationships, :mute, reading_user, target, &User.mutes?(&1, &2)), + UserRelationship.exists?( + user_relationships, + :block, + target, + reading_user, + &User.blocks_user?(&1, &2) + ), + muting: + UserRelationship.exists?( + user_relationships, + :mute, + reading_user, + target, + &User.mutes?(&1, &2) + ), muting_notifications: - test_rel( + UserRelationship.exists?( user_relationships, :notification_mute, reading_user, @@ -75,7 +109,7 @@ def render( &User.muted_notifications?(&1, &2) ), subscribing: - test_rel( + UserRelationship.exists?( user_relationships, :inverse_subscription, target, @@ -85,7 +119,7 @@ def render( requested: follow_state == "pending", domain_blocking: User.blocks_domain?(reading_user, target), showing_reblogs: - not test_rel( + not UserRelationship.exists?( user_relationships, :reblog_mute, reading_user, @@ -139,7 +173,8 @@ defp do_render("show.json", %{user: user} = opts) do render("relationship.json", %{ user: opts[:for], target: user, - user_relationships: opts[:user_relationships] + user_relationships: opts[:user_relationships], + following_relationships: opts[:following_relationships] }) %{ diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index e0c368ec9..55a5513f9 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do alias Pleroma.Activity alias Pleroma.ActivityExpiration + alias Pleroma.FollowingRelationship alias Pleroma.HTML alias Pleroma.Object alias Pleroma.Repo @@ -71,22 +72,31 @@ defp reblogged?(activity, user) do present?(user && user.ap_id in (object.data["announcements"] || [])) end - defp user_relationships_opt(opts) do + defp relationships_opts(opts) do reading_user = opts[:for] - if reading_user do - activities = opts[:activities] - actors = Enum.map(activities, fn a -> get_user(a.data["actor"]) end) + {user_relationships, following_relationships} = + if reading_user do + activities = opts[:activities] + actors = Enum.map(activities, fn a -> get_user(a.data["actor"]) end) - UserRelationship.dictionary( - [reading_user], - actors, - [:block, :mute, :notification_mute, :reblog_mute], - [:block, :inverse_subscription] - ) - else - [] - end + user_relationships = + UserRelationship.dictionary( + [reading_user], + actors, + [:block, :mute, :notification_mute, :reblog_mute], + [:block, :inverse_subscription] + ) + + following_relationships = + FollowingRelationship.all_between_user_sets([reading_user], actors) + + {user_relationships, following_relationships} + else + {[], []} + end + + %{user_relationships: user_relationships, following_relationships: following_relationships} end def render("index.json", opts) do @@ -96,7 +106,7 @@ def render("index.json", opts) do opts = opts |> Map.put(:replied_to_activities, replied_to_activities) - |> Map.put(:user_relationships, user_relationships_opt(opts)) + |> Map.merge(relationships_opts(opts)) safe_render_many(activities, StatusView, "show.json", opts) end @@ -135,7 +145,8 @@ def render( AccountView.render("show.json", %{ user: user, for: opts[:for], - user_relationships: opts[:user_relationships] + user_relationships: opts[:user_relationships], + following_relationships: opts[:following_relationships] }), in_reply_to_id: nil, in_reply_to_account_id: nil, @@ -286,7 +297,7 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} muted = thread_muted? || - Pleroma.Web.MastodonAPI.AccountView.test_rel( + UserRelationship.exists?( user_relationships_opt, :mute, opts[:for], @@ -302,7 +313,8 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} AccountView.render("show.json", %{ user: user, for: opts[:for], - user_relationships: user_relationships_opt + user_relationships: user_relationships_opt, + following_relationships: opts[:following_relationships] }), in_reply_to_id: reply_to && to_string(reply_to.id), in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id), From 5a34dca8eda46479a3459b60c623d6fa94fc662b Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 23 Mar 2020 14:03:31 +0400 Subject: [PATCH 102/188] Add emoji support in statuses in staticfe --- .../web/static_fe/static_fe_controller.ex | 4 +++- priv/static/static/static-fe.css | Bin 2629 -> 2715 bytes 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/static_fe/static_fe_controller.ex b/lib/pleroma/web/static_fe/static_fe_controller.ex index 7f9464268..7a35238d7 100644 --- a/lib/pleroma/web/static_fe/static_fe_controller.ex +++ b/lib/pleroma/web/static_fe/static_fe_controller.ex @@ -60,7 +60,9 @@ defp represent(%Activity{object: %Object{data: data}} = activity, selected) do content = if data["content"] do - Pleroma.HTML.filter_tags(data["content"]) + data["content"] + |> Pleroma.HTML.filter_tags() + |> Pleroma.Emoji.Formatter.emojify(Map.get(data, "emoji", %{})) else nil end diff --git a/priv/static/static/static-fe.css b/priv/static/static/static-fe.css index 19c56387b1ea9aa19f3d0f8d596c949f0b0c6485..db61ff2665ba2aef7f33dbfa8dbf29b7f6cdcba7 100644 GIT binary patch delta 94 zcmX>qGFxQmwgaxd3*-9ZLWJ delta 7 OcmbO&dQ@bCD;EF@j{=DR From eec1fcaf55bdcbc2d3aed4eaf044bb8ef6c4effa Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 23 Mar 2020 15:58:55 +0100 Subject: [PATCH 103/188] Home timeline tests: Add failing test for relationships --- .../controllers/timeline_controller_test.exs | 57 +++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index 6fedb4223..47849fc48 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -21,9 +21,12 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do setup do: oauth_access(["read:statuses"]) test "the home timeline", %{user: user, conn: conn} do - following = insert(:user) + following = insert(:user, nickname: "followed") + third_user = insert(:user, nickname: "repeated") - {:ok, _activity} = CommonAPI.post(following, %{"status" => "test"}) + {:ok, _activity} = CommonAPI.post(following, %{"status" => "post"}) + {:ok, activity} = CommonAPI.post(third_user, %{"status" => "repeated post"}) + {:ok, _, _} = CommonAPI.repeat(activity.id, following) ret_conn = get(conn, "/api/v1/timelines/home") @@ -31,9 +34,55 @@ test "the home timeline", %{user: user, conn: conn} do {:ok, _user} = User.follow(user, following) - conn = get(conn, "/api/v1/timelines/home") + ret_conn = get(conn, "/api/v1/timelines/home") - assert [%{"content" => "test"}] = json_response(conn, :ok) + assert [ + %{ + "reblog" => %{ + "content" => "repeated post", + "account" => %{ + "pleroma" => %{ + "relationship" => %{"following" => false, "followed_by" => false} + } + } + }, + "account" => %{"pleroma" => %{"relationship" => %{"following" => true}}} + }, + %{ + "content" => "post", + "account" => %{ + "acct" => "followed", + "pleroma" => %{"relationship" => %{"following" => true}} + } + } + ] = json_response(ret_conn, :ok) + + {:ok, _user} = User.follow(third_user, user) + + ret_conn = get(conn, "/api/v1/timelines/home") + + assert [ + %{ + "reblog" => %{ + "content" => "repeated post", + "account" => %{ + "acct" => "repeated", + "pleroma" => %{ + # This part does not match correctly + "relationship" => %{"following" => false, "followed_by" => true} + } + } + }, + "account" => %{"pleroma" => %{"relationship" => %{"following" => true}}} + }, + %{ + "content" => "post", + "account" => %{ + "acct" => "followed", + "pleroma" => %{"relationship" => %{"following" => true}} + } + } + ] = json_response(ret_conn, :ok) end test "the home timeline when the direct messages are excluded", %{user: user, conn: conn} do From 3bd2829e5c125f961b7508bf40ef534a21070562 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 23 Mar 2020 18:56:01 +0100 Subject: [PATCH 104/188] Benchmarks: Add timeline benchmark --- benchmarks/load_testing/generator.ex | 3 +- .../mix/tasks/pleroma/benchmarks/timelines.ex | 76 +++++++++++++++++++ lib/pleroma/web/controller_helper.ex | 7 +- 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 benchmarks/mix/tasks/pleroma/benchmarks/timelines.ex diff --git a/benchmarks/load_testing/generator.ex b/benchmarks/load_testing/generator.ex index 3f88fefd7..17e89c13c 100644 --- a/benchmarks/load_testing/generator.ex +++ b/benchmarks/load_testing/generator.ex @@ -22,9 +22,10 @@ def generate_like_activities(user, posts) do def generate_users(opts) do IO.puts("Starting generating #{opts[:users_max]} users...") - {time, _} = :timer.tc(fn -> do_generate_users(opts) end) + {time, users} = :timer.tc(fn -> do_generate_users(opts) end) IO.puts("Inserting users take #{to_sec(time)} sec.\n") + users end defp do_generate_users(opts) do diff --git a/benchmarks/mix/tasks/pleroma/benchmarks/timelines.ex b/benchmarks/mix/tasks/pleroma/benchmarks/timelines.ex new file mode 100644 index 000000000..dc6f3d3fc --- /dev/null +++ b/benchmarks/mix/tasks/pleroma/benchmarks/timelines.ex @@ -0,0 +1,76 @@ +defmodule Mix.Tasks.Pleroma.Benchmarks.Timelines do + use Mix.Task + alias Pleroma.Repo + alias Pleroma.LoadTesting.Generator + + alias Pleroma.Web.CommonAPI + + def run(_args) do + Mix.Pleroma.start_pleroma() + + # Cleaning tables + clean_tables() + + [{:ok, user} | users] = Generator.generate_users(users_max: 1000) + + # Let the user make 100 posts + + 1..100 + |> Enum.each(fn i -> CommonAPI.post(user, %{"status" => to_string(i)}) end) + + # Let 10 random users post + posts = + users + |> Enum.take_random(10) + |> Enum.map(fn {:ok, random_user} -> + {:ok, activity} = CommonAPI.post(random_user, %{"status" => "."}) + activity + end) + + # let our user repeat them + posts + |> Enum.each(fn activity -> + CommonAPI.repeat(activity.id, user) + end) + + Benchee.run( + %{ + "user timeline, no followers" => fn reading_user -> + conn = + Phoenix.ConnTest.build_conn() + |> Plug.Conn.assign(:user, reading_user) + |> Plug.Conn.assign(:skip_link_headers, true) + + Pleroma.Web.MastodonAPI.AccountController.statuses(conn, %{"id" => user.id}) + end + }, + inputs: %{"user" => user, "no user" => nil}, + time: 60 + ) + + users + |> Enum.each(fn {:ok, follower} -> Pleroma.User.follow(follower, user) end) + + Benchee.run( + %{ + "user timeline, all following" => fn reading_user -> + conn = + Phoenix.ConnTest.build_conn() + |> Plug.Conn.assign(:user, reading_user) + |> Plug.Conn.assign(:skip_link_headers, true) + + Pleroma.Web.MastodonAPI.AccountController.statuses(conn, %{"id" => user.id}) + end + }, + inputs: %{"user" => user, "no user" => nil}, + time: 60 + ) + end + + defp clean_tables do + IO.puts("Deleting old data...\n") + Ecto.Adapters.SQL.query!(Repo, "TRUNCATE users CASCADE;") + Ecto.Adapters.SQL.query!(Repo, "TRUNCATE activities CASCADE;") + Ecto.Adapters.SQL.query!(Repo, "TRUNCATE objects CASCADE;") + end +end diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index ad293cda9..b49523ec3 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -34,7 +34,12 @@ defp param_to_integer(val, default) when is_binary(val) do defp param_to_integer(_, default), do: default - def add_link_headers(conn, activities, extra_params \\ %{}) do + def add_link_headers(conn, activities, extra_params \\ %{}) + + def add_link_headers(%{assigns: %{skip_link_headers: true}} = conn, _activities, _extra_params), + do: conn + + def add_link_headers(conn, activities, extra_params) do case List.last(activities) do %{id: max_id} -> params = From d1a9716a988fe9f670033ad46cc9637038fbd1e8 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 24 Mar 2020 17:38:18 +0400 Subject: [PATCH 105/188] Fix activity deletion --- lib/pleroma/web/activity_pub/activity_pub.ex | 10 ++++++++++ test/web/activity_pub/activity_pub_test.exs | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 30e282840..974231925 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -583,6 +583,16 @@ defp do_delete(%Object{data: %{"id" => id, "actor" => actor}} = object, options) 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 a43dd34f0..049b14498 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -1425,6 +1425,12 @@ test "it creates a delete activity and deletes the original object" do 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) From 74560e888e5e3e4dc2fa5b4fec4cf3986a1d1a55 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 24 Mar 2020 18:20:58 +0000 Subject: [PATCH 106/188] Apply suggestion to lib/pleroma/web/activity_pub/object_validators/create_validator.ex --- .../web/activity_pub/object_validators/create_validator.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/create_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_validator.ex index 9e480c4ed..872a12c48 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_validator.ex @@ -25,7 +25,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateNoteValidator do end def cast_data(data) do - %__MODULE__{} - |> cast(data, __schema__(:fields)) + cast(%__MODULE__{}, data, __schema__(:fields)) end end From aaf00f1ff59fc279758f5fa5ceaf758d683bd216 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 24 Mar 2020 18:24:09 +0000 Subject: [PATCH 107/188] Apply suggestion to lib/pleroma/web/activity_pub/pipeline.ex --- lib/pleroma/web/activity_pub/pipeline.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index cb3571917..25f29bf63 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -35,7 +35,7 @@ defp maybe_federate(activity, meta) do {:ok, :not_federated} end else - _e -> {:error, "local not set in meta"} + _e -> {:error, :badarg} end end end From f31688246470273cc35588d0f1c2187edc6084c7 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Tue, 24 Mar 2020 18:37:53 +0000 Subject: [PATCH 108/188] Apply suggestion to lib/pleroma/web/activity_pub/activity_pub.ex --- lib/pleroma/web/activity_pub/activity_pub.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index d9f30e629..dd4b04185 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -150,7 +150,6 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when {_, true} <- {:remote_limit_error, check_remote_limit(map)}, {:ok, map} <- MRF.filter(map), {recipients, _, _} = get_recipients(map), - # ??? {:fake, false, map, recipients} <- {:fake, fake, map, recipients}, {:containment, :ok} <- {:containment, Containment.contain_child(map)}, {:ok, map, object} <- insert_full_object(map) do From 13cbb9f6ada8dcb15bb7ed12be4d88a18c5db7f7 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Tue, 24 Mar 2020 22:14:26 +0300 Subject: [PATCH 109/188] Implemented preloading of relationships with parent activities' actors for statuses/timeline rendering. Applied preloading for notifications rendering. Fixed announces rendering issue (preloading-related). --- lib/pleroma/activity/queries.ex | 7 ++ .../web/mastodon_api/views/account_view.ex | 15 ++- .../mastodon_api/views/notification_view.ex | 98 +++++++++++++++---- .../web/mastodon_api/views/status_view.ex | 85 +++++++++------- .../controllers/timeline_controller_test.exs | 1 - 5 files changed, 138 insertions(+), 68 deletions(-) diff --git a/lib/pleroma/activity/queries.ex b/lib/pleroma/activity/queries.ex index 04593b9fb..a34c20343 100644 --- a/lib/pleroma/activity/queries.ex +++ b/lib/pleroma/activity/queries.ex @@ -35,6 +35,13 @@ def by_author(query \\ Activity, %User{ap_id: ap_id}) do from(a in query, where: a.actor == ^ap_id) end + def find_by_object_ap_id(activities, object_ap_id) do + Enum.find( + activities, + &(object_ap_id in [is_map(&1.data["object"]) && &1.data["object"]["id"], &1.data["object"]]) + ) + end + @spec by_object_id(query, String.t() | [String.t()]) :: query def by_object_id(query \\ Activity, object_id) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 2fe46158b..89bea9957 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -46,8 +46,8 @@ def render( "relationship.json", %{user: %User{} = reading_user, target: %User{} = target} = opts ) do - user_relationships = Map.get(opts, :user_relationships) - following_relationships = opts[:following_relationships] + user_relationships = get_in(opts, [:relationships, :user_relationships]) + following_relationships = get_in(opts, [:relationships, :following_relationships]) follow_state = if following_relationships do @@ -61,17 +61,15 @@ def render( followed_by = if following_relationships do - with %{state: "accept"} <- - find_following_rel(following_relationships, target, reading_user) do - true - else + case find_following_rel(following_relationships, target, reading_user) do + %{state: "accept"} -> true _ -> false end else User.following?(target, reading_user) end - # TODO: add a note on adjusting StatusView.user_relationships_opt/1 re: preloading of user relations + # NOTE: adjust StatusView.relationships_opts/2 if adding new relation-related flags %{ id: to_string(target.id), following: follow_state == "accept", @@ -173,8 +171,7 @@ defp do_render("show.json", %{user: user} = opts) do render("relationship.json", %{ user: opts[:for], target: user, - user_relationships: opts[:user_relationships], - following_relationships: opts[:following_relationships] + relationships: opts[:relationships] }) %{ diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 33145c484..e9c618496 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -13,19 +13,68 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView - def render("index.json", %{notifications: notifications, for: user}) do - safe_render_many(notifications, NotificationView, "show.json", %{for: user}) + def render("index.json", %{notifications: notifications, for: reading_user}) do + activities = Enum.map(notifications, & &1.activity) + + parent_activities = + activities + |> Enum.filter( + &(Activity.mastodon_notification_type(&1) in [ + "favourite", + "reblog", + "pleroma:emoji_reaction" + ]) + ) + |> Enum.map(& &1.data["object"]) + |> Activity.create_by_object_ap_id() + |> Activity.with_preloaded_object(:left) + |> Pleroma.Repo.all() + + move_activities_targets = + activities + |> Enum.filter(&(Activity.mastodon_notification_type(&1) == "move")) + |> Enum.map(&User.get_cached_by_ap_id(&1.data["target"])) + + actors = + activities + |> Enum.map(fn a -> User.get_cached_by_ap_id(a.data["actor"]) end) + |> Enum.filter(& &1) + |> Kernel.++(move_activities_targets) + + opts = %{ + for: reading_user, + parent_activities: parent_activities, + relationships: StatusView.relationships_opts(reading_user, actors) + } + + safe_render_many(notifications, NotificationView, "show.json", opts) end - def render("show.json", %{ - notification: %Notification{activity: activity} = notification, - for: user - }) do + def render( + "show.json", + %{ + notification: %Notification{activity: activity} = notification, + for: reading_user + } = opts + ) do actor = User.get_cached_by_ap_id(activity.data["actor"]) - parent_activity = Activity.get_create_by_object_ap_id(activity.data["object"]) + + parent_activity_fn = fn -> + if opts[:parent_activities] do + Activity.Queries.find_by_object_ap_id(opts[:parent_activities], activity.data["object"]) + else + Activity.get_create_by_object_ap_id(activity.data["object"]) + end + end + mastodon_type = Activity.mastodon_notification_type(activity) - with %{id: _} = account <- AccountView.render("show.json", %{user: actor, for: user}) do + with %{id: _} = account <- + AccountView.render("show.json", %{ + user: actor, + for: reading_user, + relationships: opts[:relationships] + }) do response = %{ id: to_string(notification.id), type: mastodon_type, @@ -36,24 +85,28 @@ def render("show.json", %{ } } + relationships_opts = %{relationships: opts[:relationships]} + case mastodon_type do "mention" -> - put_status(response, activity, user) + put_status(response, activity, reading_user, relationships_opts) "favourite" -> - put_status(response, parent_activity, user) + put_status(response, parent_activity_fn.(), reading_user, relationships_opts) "reblog" -> - put_status(response, parent_activity, user) + put_status(response, parent_activity_fn.(), reading_user, relationships_opts) "move" -> - put_target(response, activity, user) + put_target(response, activity, reading_user, relationships_opts) "follow" -> response "pleroma:emoji_reaction" -> - put_status(response, parent_activity, user) |> put_emoji(activity) + response + |> put_status(parent_activity_fn.(), reading_user, relationships_opts) + |> put_emoji(activity) _ -> nil @@ -64,16 +117,21 @@ def render("show.json", %{ end defp put_emoji(response, activity) do - response - |> Map.put(:emoji, activity.data["content"]) + Map.put(response, :emoji, activity.data["content"]) end - defp put_status(response, activity, user) do - Map.put(response, :status, StatusView.render("show.json", %{activity: activity, for: user})) + defp put_status(response, activity, reading_user, opts) do + status_render_opts = Map.merge(opts, %{activity: activity, for: reading_user}) + status_render = StatusView.render("show.json", status_render_opts) + + Map.put(response, :status, status_render) end - defp put_target(response, activity, user) do - target = User.get_cached_by_ap_id(activity.data["target"]) - Map.put(response, :target, AccountView.render("show.json", %{user: target, for: user})) + defp put_target(response, activity, reading_user, opts) do + target_user = User.get_cached_by_ap_id(activity.data["target"]) + target_render_opts = Map.merge(opts, %{user: target_user, for: reading_user}) + target_render = AccountView.render("show.json", target_render_opts) + + Map.put(response, :target, target_render) end end diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 55a5513f9..0ef65b352 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -72,41 +72,46 @@ defp reblogged?(activity, user) do present?(user && user.ap_id in (object.data["announcements"] || [])) end - defp relationships_opts(opts) do - reading_user = opts[:for] + def relationships_opts(_reading_user = nil, _actors) do + %{user_relationships: [], following_relationships: []} + end - {user_relationships, following_relationships} = - if reading_user do - activities = opts[:activities] - actors = Enum.map(activities, fn a -> get_user(a.data["actor"]) end) + def relationships_opts(reading_user, actors) do + user_relationships = + UserRelationship.dictionary( + [reading_user], + actors, + [:block, :mute, :notification_mute, :reblog_mute], + [:block, :inverse_subscription] + ) - user_relationships = - UserRelationship.dictionary( - [reading_user], - actors, - [:block, :mute, :notification_mute, :reblog_mute], - [:block, :inverse_subscription] - ) - - following_relationships = - FollowingRelationship.all_between_user_sets([reading_user], actors) - - {user_relationships, following_relationships} - else - {[], []} - end + following_relationships = FollowingRelationship.all_between_user_sets([reading_user], actors) %{user_relationships: user_relationships, following_relationships: following_relationships} end def render("index.json", opts) do - activities = opts.activities + # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list + activities = Enum.filter(opts.activities, & &1) replied_to_activities = get_replied_to_activities(activities) + parent_activities = + activities + |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"])) + |> Enum.map(&Object.normalize(&1).data["id"]) + |> Activity.create_by_object_ap_id() + |> Activity.with_preloaded_object(:left) + |> Activity.with_preloaded_bookmark(opts[:for]) + |> Activity.with_set_thread_muted_field(opts[:for]) + |> Repo.all() + + actors = Enum.map(activities ++ parent_activities, &get_user(&1.data["actor"])) + opts = opts |> Map.put(:replied_to_activities, replied_to_activities) - |> Map.merge(relationships_opts(opts)) + |> Map.put(:parent_activities, parent_activities) + |> Map.put(:relationships, relationships_opts(opts[:for], actors)) safe_render_many(activities, StatusView, "show.json", opts) end @@ -119,17 +124,25 @@ def render( created_at = Utils.to_masto_date(activity.data["published"]) activity_object = Object.normalize(activity) - reblogged_activity = - Activity.create_by_object_ap_id(activity_object.data["id"]) - |> Activity.with_preloaded_bookmark(opts[:for]) - |> Activity.with_set_thread_muted_field(opts[:for]) - |> Repo.one() + reblogged_parent_activity = + if opts[:parent_activities] do + Activity.Queries.find_by_object_ap_id( + opts[:parent_activities], + activity_object.data["id"] + ) + else + Activity.create_by_object_ap_id(activity_object.data["id"]) + |> Activity.with_preloaded_bookmark(opts[:for]) + |> Activity.with_set_thread_muted_field(opts[:for]) + |> Repo.one() + end - reblogged = render("show.json", Map.put(opts, :activity, reblogged_activity)) + reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity) + reblogged = render("show.json", reblog_rendering_opts) favorited = opts[:for] && opts[:for].ap_id in (activity_object.data["likes"] || []) - bookmarked = Activity.get_bookmark(reblogged_activity, opts[:for]) != nil + bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil mentions = activity.recipients @@ -145,8 +158,7 @@ def render( AccountView.render("show.json", %{ user: user, for: opts[:for], - user_relationships: opts[:user_relationships], - following_relationships: opts[:following_relationships] + relationships: opts[:relationships] }), in_reply_to_id: nil, in_reply_to_account_id: nil, @@ -156,7 +168,7 @@ def render( reblogs_count: 0, replies_count: 0, favourites_count: 0, - reblogged: reblogged?(reblogged_activity, opts[:for]), + reblogged: reblogged?(reblogged_parent_activity, opts[:for]), favourited: present?(favorited), bookmarked: present?(bookmarked), muted: false, @@ -293,12 +305,10 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} _ -> [] end - user_relationships_opt = opts[:user_relationships] - muted = thread_muted? || UserRelationship.exists?( - user_relationships_opt, + get_in(opts, [:relationships, :user_relationships]), :mute, opts[:for], user, @@ -313,8 +323,7 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} AccountView.render("show.json", %{ user: user, for: opts[:for], - user_relationships: user_relationships_opt, - following_relationships: opts[:following_relationships] + relationships: opts[:relationships] }), in_reply_to_id: reply_to && to_string(reply_to.id), in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id), diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index 47849fc48..97b1c3e66 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -68,7 +68,6 @@ test "the home timeline", %{user: user, conn: conn} do "account" => %{ "acct" => "repeated", "pleroma" => %{ - # This part does not match correctly "relationship" => %{"following" => false, "followed_by" => true} } } From 64165d1df95bc3a22260dafa4584471427685864 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 24 Mar 2020 20:21:27 +0100 Subject: [PATCH 110/188] node_info_test.exs: Add test on the default feature list --- test/web/node_info_test.exs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/web/node_info_test.exs b/test/web/node_info_test.exs index ee10ad5db..e8922a8ee 100644 --- a/test/web/node_info_test.exs +++ b/test/web/node_info_test.exs @@ -128,6 +128,27 @@ test "it shows if federation is enabled/disabled", %{conn: conn} do end end + test "it shows default features flags", %{conn: conn} do + response = + conn + |> get("/nodeinfo/2.1.json") + |> json_response(:ok) + + assert response["metadata"]["features"] -- + [ + "pleroma_api", + "mastodon_api", + "mastodon_api_streaming", + "polls", + "pleroma_explicit_addressing", + "shareable_emoji_packs", + "multifetch", + "chat", + "relay", + "pleroma_emoji_reactions" + ] == [] + end + test "it shows MRF transparency data if enabled", %{conn: conn} do config = Pleroma.Config.get([:instance, :rewrite_policy]) Pleroma.Config.put([:instance, :rewrite_policy], [Pleroma.Web.ActivityPub.MRF.SimplePolicy]) From 03a18cf037d7a9b4ba84ff456b434d65e3290965 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 24 Mar 2020 20:39:19 +0100 Subject: [PATCH 111/188] node_info_test: Bump default features list --- test/web/node_info_test.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/web/node_info_test.exs b/test/web/node_info_test.exs index 01a67afd7..e5eebced1 100644 --- a/test/web/node_info_test.exs +++ b/test/web/node_info_test.exs @@ -145,7 +145,8 @@ test "it shows default features flags", %{conn: conn} do "multifetch", "chat", "relay", - "pleroma_emoji_reactions" + "pleroma_emoji_reactions", + "pleroma:api/v1/notifications:include_types_filter" ] == [] end From e743c2232970e321c833604b232520587ad8e402 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 25 Mar 2020 09:04:00 +0300 Subject: [PATCH 112/188] Fixed incorrect usage of "relations" as a short form of "relationships". --- config/description.exs | 2 +- lib/pleroma/notification.ex | 6 +++--- lib/pleroma/user.ex | 20 +++++++++---------- lib/pleroma/web/activity_pub/activity_pub.ex | 8 ++++---- .../controllers/account_controller.ex | 10 +++++++--- lib/pleroma/web/streamer/worker.ex | 2 +- test/user_test.exs | 6 +++--- 7 files changed, 29 insertions(+), 25 deletions(-) diff --git a/config/description.exs b/config/description.exs index 732c76734..68fa8b03b 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2442,7 +2442,7 @@ %{ key: :relations_actions, type: [:tuple, {:list, :tuple}], - description: "For actions on relations with all users (follow, unfollow)", + description: "For actions on relationships with all users (follow, unfollow)", suggestions: [{1000, 10}, [{10_000, 10}, {10_000, 50}]] }, %{ diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 104368fd1..bc691dce3 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -39,11 +39,11 @@ def changeset(%Notification{} = notification, attrs) do end defp for_user_query_ap_id_opts(user, opts) do - ap_id_relations = + ap_id_relationships = [:block] ++ if opts[@include_muted_option], do: [], else: [:notification_mute] - preloaded_ap_ids = User.outgoing_relations_ap_ids(user, ap_id_relations) + preloaded_ap_ids = User.outgoing_relationships_ap_ids(user, ap_id_relationships) exclude_blocked_opts = Map.merge(%{blocked_users_ap_ids: preloaded_ap_ids[:block]}, opts) @@ -370,7 +370,7 @@ def exclude_relation_restricting_ap_ids(ap_ids, %Activity{} = activity) do relation_restricted_ap_ids = activity |> Activity.user_actor() - |> User.incoming_relations_ungrouped_ap_ids([ + |> User.incoming_relationships_ungrouped_ap_ids([ :block, :notification_mute ]) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 05efc74d4..4919c8e58 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1222,15 +1222,15 @@ def subscribed_to?(%User{} = user, %{ap_id: ap_id}) do end @doc """ - Returns map of outgoing (blocked, muted etc.) relations' user AP IDs by relation type. - E.g. `outgoing_relations_ap_ids(user, [:block])` -> `%{block: ["https://some.site/users/userapid"]}` + Returns map of outgoing (blocked, muted etc.) relationships' user AP IDs by relation type. + E.g. `outgoing_relationships_ap_ids(user, [:block])` -> `%{block: ["https://some.site/users/userapid"]}` """ - @spec outgoing_relations_ap_ids(User.t(), list(atom())) :: %{atom() => list(String.t())} - def outgoing_relations_ap_ids(_user, []), do: %{} + @spec outgoing_relationships_ap_ids(User.t(), list(atom())) :: %{atom() => list(String.t())} + def outgoing_relationships_ap_ids(_user, []), do: %{} - def outgoing_relations_ap_ids(nil, _relationship_types), do: %{} + def outgoing_relationships_ap_ids(nil, _relationship_types), do: %{} - def outgoing_relations_ap_ids(%User{} = user, relationship_types) + def outgoing_relationships_ap_ids(%User{} = user, relationship_types) when is_list(relationship_types) do db_result = user @@ -1249,13 +1249,13 @@ def outgoing_relations_ap_ids(%User{} = user, relationship_types) ) end - def incoming_relations_ungrouped_ap_ids(user, relationship_types, ap_ids \\ nil) + def incoming_relationships_ungrouped_ap_ids(user, relationship_types, ap_ids \\ nil) - def incoming_relations_ungrouped_ap_ids(_user, [], _ap_ids), do: [] + def incoming_relationships_ungrouped_ap_ids(_user, [], _ap_ids), do: [] - def incoming_relations_ungrouped_ap_ids(nil, _relationship_types, _ap_ids), do: [] + def incoming_relationships_ungrouped_ap_ids(nil, _relationship_types, _ap_ids), do: [] - def incoming_relations_ungrouped_ap_ids(%User{} = user, relationship_types, ap_ids) + def incoming_relationships_ungrouped_ap_ids(%User{} = user, relationship_types, ap_ids) when is_list(relationship_types) do user |> assoc(:incoming_relationships) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index d9f74b6a4..60e74758f 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1230,17 +1230,17 @@ defp maybe_order(query, _), do: query defp fetch_activities_query_ap_ids_ops(opts) do source_user = opts["muting_user"] - ap_id_relations = if source_user, do: [:mute, :reblog_mute], else: [] + ap_id_relationships = if source_user, do: [:mute, :reblog_mute], else: [] - ap_id_relations = - ap_id_relations ++ + ap_id_relationships = + ap_id_relationships ++ if opts["blocking_user"] && opts["blocking_user"] == source_user do [:block] else [] end - preloaded_ap_ids = User.outgoing_relations_ap_ids(source_user, ap_id_relations) + preloaded_ap_ids = User.outgoing_relationships_ap_ids(source_user, ap_id_relationships) restrict_blocked_opts = Map.merge(%{"blocked_users_ap_ids" => preloaded_ap_ids[:block]}, opts) restrict_muted_opts = Map.merge(%{"muted_users_ap_ids" => preloaded_ap_ids[:mute]}, opts) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 88c997b9f..9d83a9fc1 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -63,11 +63,15 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do when action != :create ) - @relations [:follow, :unfollow] + @relationship_actions [:follow, :unfollow] @needs_account ~W(followers following lists follow unfollow mute unmute block unblock)a - plug(RateLimiter, [name: :relations_id_action, params: ["id", "uri"]] when action in @relations) - plug(RateLimiter, [name: :relations_actions] when action in @relations) + plug( + RateLimiter, + [name: :relation_id_action, params: ["id", "uri"]] when action in @relationship_actions + ) + + plug(RateLimiter, [name: :relations_actions] when action in @relationship_actions) plug(RateLimiter, [name: :app_account_creation] when action == :create) plug(:assign_account_by_id when action in @needs_account) diff --git a/lib/pleroma/web/streamer/worker.ex b/lib/pleroma/web/streamer/worker.ex index 29f992a67..abfed21c8 100644 --- a/lib/pleroma/web/streamer/worker.ex +++ b/lib/pleroma/web/streamer/worker.ex @@ -130,7 +130,7 @@ defp do_stream(%{topic: topic, item: item}) do defp should_send?(%User{} = user, %Activity{} = item) do %{block: blocked_ap_ids, mute: muted_ap_ids, reblog_mute: reblog_muted_ap_ids} = - User.outgoing_relations_ap_ids(user, [:block, :mute, :reblog_mute]) + 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) diff --git a/test/user_test.exs b/test/user_test.exs index b07fed42b..f3d044a80 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -86,7 +86,7 @@ test "returns invisible actor" do {:ok, user: insert(:user)} end - test "outgoing_relations_ap_ids/1", %{user: user} do + test "outgoing_relationships_ap_ids/1", %{user: user} do rel_types = [:block, :mute, :notification_mute, :reblog_mute, :inverse_subscription] ap_ids_by_rel = @@ -124,10 +124,10 @@ test "outgoing_relations_ap_ids/1", %{user: user} do assert ap_ids_by_rel[:inverse_subscription] == Enum.sort(Enum.map(User.subscriber_users(user), & &1.ap_id)) - outgoing_relations_ap_ids = User.outgoing_relations_ap_ids(user, rel_types) + outgoing_relationships_ap_ids = User.outgoing_relationships_ap_ids(user, rel_types) assert ap_ids_by_rel == - Enum.into(outgoing_relations_ap_ids, %{}, fn {k, v} -> {k, Enum.sort(v)} end) + Enum.into(outgoing_relationships_ap_ids, %{}, fn {k, v} -> {k, Enum.sort(v)} end) end end From 3fa3d45dbecafb06fb7eb4f0260f610d4225e0a7 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 25 Mar 2020 13:05:00 +0300 Subject: [PATCH 113/188] [#1364] Minor improvements / comments. Further fixes of incorrect usage of "relations" as a short form of "relationships". --- lib/pleroma/activity.ex | 1 + lib/pleroma/notification.ex | 12 +++++++----- lib/pleroma/thread_mute.ex | 7 ++++--- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index bbaa561a7..5a8329e69 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -95,6 +95,7 @@ def with_preloaded_object(query, join_type \\ :inner) do |> preload([activity, object: object], object: object) end + # Note: applies to fake activities (ActivityPub.Utils.get_notified_from_object/1 etc.) def user_actor(%Activity{actor: nil}), do: nil def user_actor(%Activity{} = activity) do diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 63e3e9be9..04ee510b9 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -322,6 +322,8 @@ def create_notification(%Activity{} = activity, %User{} = user, do_send \\ true) @doc """ Returns a tuple with 2 elements: {enabled notification receivers, currently disabled receivers (blocking / [thread] muting)} + + NOTE: might be called for FAKE Activities, see ActivityPub.Utils.get_notified_from_object/1 """ def get_notified_from_activity(activity, local_only \\ true) @@ -338,7 +340,7 @@ def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, lo # Since even subscribers and followers can mute / thread-mute, filtering all above AP IDs notification_enabled_ap_ids = potential_receiver_ap_ids - |> exclude_relation_restricting_ap_ids(activity) + |> exclude_relationship_restricted_ap_ids(activity) |> exclude_thread_muter_ap_ids(activity) potential_receivers = @@ -355,10 +357,10 @@ def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, lo def get_notified_from_activity(_, _local_only), do: {[], []} @doc "Filters out AP IDs of users basing on their relationships with activity actor user" - def exclude_relation_restricting_ap_ids([], _activity), do: [] + def exclude_relationship_restricted_ap_ids([], _activity), do: [] - def exclude_relation_restricting_ap_ids(ap_ids, %Activity{} = activity) do - relation_restricted_ap_ids = + def exclude_relationship_restricted_ap_ids(ap_ids, %Activity{} = activity) do + relationship_restricted_ap_ids = activity |> Activity.user_actor() |> User.incoming_relationships_ungrouped_ap_ids([ @@ -366,7 +368,7 @@ def exclude_relation_restricting_ap_ids(ap_ids, %Activity{} = activity) do :notification_mute ]) - Enum.uniq(ap_ids) -- relation_restricted_ap_ids + Enum.uniq(ap_ids) -- relationship_restricted_ap_ids end @doc "Filters out AP IDs of users who mute activity thread" diff --git a/lib/pleroma/thread_mute.ex b/lib/pleroma/thread_mute.ex index 2b4cf02cf..a7ea13891 100644 --- a/lib/pleroma/thread_mute.ex +++ b/lib/pleroma/thread_mute.ex @@ -41,15 +41,16 @@ def muters_query(context) do def muter_ap_ids(context, ap_ids \\ nil) - def muter_ap_ids(context, ap_ids) when context not in [nil, ""] do + # Note: applies to fake activities (ActivityPub.Utils.get_notified_from_object/1 etc.) + def muter_ap_ids(context, _ap_ids) when is_nil(context), do: [] + + def muter_ap_ids(context, ap_ids) do context |> muters_query() |> maybe_filter_on_ap_id(ap_ids) |> Repo.all() end - def muter_ap_ids(_context, _ap_ids), do: [] - defp maybe_filter_on_ap_id(query, ap_ids) when is_list(ap_ids) do where(query, [tm, u], u.ap_id in ^ap_ids) end From be5e2c4dbba63831ea6a0617556e686969b5080f Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 25 Mar 2020 17:01:45 +0300 Subject: [PATCH 114/188] Applied relationships preloading to GET /api/v1/accounts/relationships. Refactoring (User.binary_id/1). --- lib/pleroma/conversation/participation.ex | 11 ++++------- lib/pleroma/following_relationship.ex | 8 ++------ lib/pleroma/thread_mute.ex | 4 ++-- lib/pleroma/user.ex | 15 +++++++++++++++ lib/pleroma/user_relationship.ex | 9 ++------- .../web/mastodon_api/views/account_view.ex | 6 +++++- 6 files changed, 30 insertions(+), 23 deletions(-) diff --git a/lib/pleroma/conversation/participation.ex b/lib/pleroma/conversation/participation.ex index 693825cf5..215265fc9 100644 --- a/lib/pleroma/conversation/participation.ex +++ b/lib/pleroma/conversation/participation.ex @@ -129,21 +129,18 @@ def for_user(user, params \\ %{}) do end def restrict_recipients(query, user, %{"recipients" => user_ids}) do - user_ids = + user_binary_ids = [user.id | user_ids] |> Enum.uniq() - |> Enum.reduce([], fn user_id, acc -> - {:ok, user_id} = FlakeId.Ecto.CompatType.dump(user_id) - [user_id | acc] - end) + |> User.binary_id() conversation_subquery = __MODULE__ |> group_by([p], p.conversation_id) |> having( [p], - count(p.user_id) == ^length(user_ids) and - fragment("array_agg(?) @> ?", p.user_id, ^user_ids) + count(p.user_id) == ^length(user_binary_ids) and + fragment("array_agg(?) @> ?", p.user_id, ^user_binary_ids) ) |> select([p], %{id: p.conversation_id}) diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index dd1696136..624bddfe4 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -135,12 +135,8 @@ def all_between_user_sets( target_users ) when is_list(source_users) and is_list(target_users) do - get_bin_ids = fn user -> - with {:ok, bin_id} <- CompatType.dump(user.id), do: bin_id - end - - source_user_ids = Enum.map(source_users, &get_bin_ids.(&1)) - target_user_ids = Enum.map(target_users, &get_bin_ids.(&1)) + source_user_ids = User.binary_id(source_users) + target_user_ids = User.binary_id(target_users) __MODULE__ |> where( diff --git a/lib/pleroma/thread_mute.ex b/lib/pleroma/thread_mute.ex index cc815430a..f657758aa 100644 --- a/lib/pleroma/thread_mute.ex +++ b/lib/pleroma/thread_mute.ex @@ -24,10 +24,10 @@ def changeset(mute, params \\ %{}) do end def query(user_id, context) do - {:ok, user_id} = FlakeId.Ecto.CompatType.dump(user_id) + user_binary_id = User.binary_id(user_id) ThreadMute - |> Ecto.Query.where(user_id: ^user_id) + |> Ecto.Query.where(user_id: ^user_binary_id) |> Ecto.Query.where(context: ^context) end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index f74e43cce..699256a3b 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -218,6 +218,21 @@ def unquote(:"#{outgoing_relation_target}_ap_ids")(user, restrict_deactivated? \ end end + @doc "Dumps id to SQL-compatible format" + def binary_id(source_id) when is_binary(source_id) do + with {:ok, dumped_id} <- FlakeId.Ecto.CompatType.dump(source_id) do + dumped_id + else + _ -> source_id + end + end + + def binary_id(source_ids) when is_list(source_ids) do + Enum.map(source_ids, &binary_id/1) + end + + def binary_id(%User{} = user), do: binary_id(user.id) + @doc "Returns status account" @spec account_status(User.t()) :: account_status() def account_status(%User{deactivated: true}), do: :deactivated diff --git a/lib/pleroma/user_relationship.ex b/lib/pleroma/user_relationship.ex index 9423e3a42..519d2998d 100644 --- a/lib/pleroma/user_relationship.ex +++ b/lib/pleroma/user_relationship.ex @@ -8,7 +8,6 @@ defmodule Pleroma.UserRelationship do import Ecto.Changeset import Ecto.Query - alias FlakeId.Ecto.CompatType alias Pleroma.Repo alias Pleroma.User alias Pleroma.UserRelationship @@ -84,12 +83,8 @@ def dictionary( target_to_source_rel_types \\ nil ) when is_list(source_users) and is_list(target_users) do - get_bin_ids = fn user -> - with {:ok, bin_id} <- CompatType.dump(user.id), do: bin_id - end - - source_user_ids = Enum.map(source_users, &get_bin_ids.(&1)) - target_user_ids = Enum.map(target_users, &get_bin_ids.(&1)) + source_user_ids = User.binary_id(source_users) + target_user_ids = User.binary_id(target_users) get_rel_type_codes = fn rel_type -> user_relationship_mappings()[rel_type] end diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 702d9e658..6b2eca1f3 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do alias Pleroma.UserRelationship alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MediaProxy defp find_following_rel(following_relationships, follower, following) do @@ -129,7 +130,10 @@ def render( end def render("relationships.json", %{user: user, targets: targets}) do - render_many(targets, AccountView, "relationship.json", user: user, as: :target) + relationships_opts = StatusView.relationships_opts(user, targets) + opts = %{as: :target, user: user, relationships: relationships_opts} + + render_many(targets, AccountView, "relationship.json", opts) end defp do_render("show.json", %{user: user} = opts) do From 9081a071eecd0eeb4b67008754555e9c9d73eae7 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 25 Mar 2020 18:46:17 +0400 Subject: [PATCH 115/188] Add a test for accounts/update_credentials --- .../account_controller/update_credentials_test.exs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs index 51cebe567..b693c1a47 100644 --- a/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs +++ b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs @@ -118,6 +118,18 @@ test "updates the user's hide_followers status", %{conn: conn} do assert user_data["pleroma"]["hide_followers"] == true end + test "updates the user's discoverable status", %{conn: conn} do + assert %{"source" => %{"pleroma" => %{"discoverable" => true}}} = + conn + |> patch("/api/v1/accounts/update_credentials", %{discoverable: "true"}) + |> json_response(:ok) + + assert %{"source" => %{"pleroma" => %{"discoverable" => false}}} = + conn + |> patch("/api/v1/accounts/update_credentials", %{discoverable: "false"}) + |> json_response(:ok) + end + test "updates the user's hide_followers_count and hide_follows_count", %{conn: conn} do conn = patch(conn, "/api/v1/accounts/update_credentials", %{ From c8475cd5c63af18471864fe57504999ddd09e496 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 25 Mar 2020 15:48:15 +0000 Subject: [PATCH 116/188] Apply suggestion to benchmarks/load_testing/generator.ex --- benchmarks/load_testing/generator.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/load_testing/generator.ex b/benchmarks/load_testing/generator.ex index 17e89c13c..e4673757c 100644 --- a/benchmarks/load_testing/generator.ex +++ b/benchmarks/load_testing/generator.ex @@ -24,7 +24,7 @@ def generate_users(opts) do IO.puts("Starting generating #{opts[:users_max]} users...") {time, users} = :timer.tc(fn -> do_generate_users(opts) end) - IO.puts("Inserting users take #{to_sec(time)} sec.\n") + IO.puts("Inserting users took #{to_sec(time)} sec.\n") users end From 460e41585c2cd3f137c0f80173da60167fb318bf Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 25 Mar 2020 20:33:34 +0300 Subject: [PATCH 117/188] Further preloading (more endpoints), refactoring, tests. --- lib/pleroma/following_relationship.ex | 6 + lib/pleroma/user.ex | 5 +- lib/pleroma/user_relationship.ex | 20 ++++ .../web/mastodon_api/views/account_view.ex | 36 +++--- .../mastodon_api/views/notification_view.ex | 42 ++++--- .../web/mastodon_api/views/status_view.ex | 29 ++--- .../mastodon_api/views/account_view_test.exs | 109 ++++++++++-------- .../views/notification_view_test.exs | 42 +++---- .../mastodon_api/views/status_view_test.exs | 15 ++- 9 files changed, 179 insertions(+), 125 deletions(-) diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index 624bddfe4..a9538ea4e 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -151,4 +151,10 @@ def all_between_user_sets( ) |> Repo.all() end + + def find(following_relationships, follower, following) do + Enum.find(following_relationships, fn + fr -> fr.follower_id == follower.id and fr.following_id == following.id + end) + end end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 699256a3b..8ccb9242d 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -218,7 +218,10 @@ def unquote(:"#{outgoing_relation_target}_ap_ids")(user, restrict_deactivated? \ end end - @doc "Dumps id to SQL-compatible format" + @doc """ + Dumps Flake Id to SQL-compatible format (16-byte UUID). + E.g. "9pQtDGXuq4p3VlcJEm" -> <<0, 0, 1, 110, 179, 218, 42, 92, 213, 41, 44, 227, 95, 213, 0, 0>> + """ def binary_id(source_id) when is_binary(source_id) do with {:ok, dumped_id} <- FlakeId.Ecto.CompatType.dump(source_id) do dumped_id diff --git a/lib/pleroma/user_relationship.ex b/lib/pleroma/user_relationship.ex index 519d2998d..011cf6822 100644 --- a/lib/pleroma/user_relationship.ex +++ b/lib/pleroma/user_relationship.ex @@ -8,6 +8,7 @@ defmodule Pleroma.UserRelationship do import Ecto.Changeset import Ecto.Query + alias Pleroma.FollowingRelationship alias Pleroma.Repo alias Pleroma.User alias Pleroma.UserRelationship @@ -124,6 +125,25 @@ def exists?(dictionary, rel_type, source, target, func) do end end + @doc ":relationships option for StatusView / AccountView / NotificationView" + def view_relationships_option(nil = _reading_user, _actors) do + %{user_relationships: [], following_relationships: []} + end + + def view_relationships_option(%User{} = reading_user, actors) do + user_relationships = + UserRelationship.dictionary( + [reading_user], + actors, + [:block, :mute, :notification_mute, :reblog_mute], + [:block, :inverse_subscription] + ) + + following_relationships = FollowingRelationship.all_between_user_sets([reading_user], actors) + + %{user_relationships: user_relationships, following_relationships: following_relationships} + end + defp validate_not_self_relationship(%Ecto.Changeset{} = changeset) do changeset |> validate_change(:target_id, fn _, target_id -> diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 6b2eca1f3..2cdfac7af 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -5,20 +5,23 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do use Pleroma.Web, :view + alias Pleroma.FollowingRelationship alias Pleroma.User alias Pleroma.UserRelationship alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView - alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MediaProxy - defp find_following_rel(following_relationships, follower, following) do - Enum.find(following_relationships, fn - fr -> fr.follower_id == follower.id and fr.following_id == following.id - end) - end - def render("index.json", %{users: users} = opts) do + relationships_opt = + if Map.has_key?(opts, :relationships) do + opts[:relationships] + else + UserRelationship.view_relationships_option(opts[:for], users) + end + + opts = Map.put(opts, :relationships, relationships_opt) + users |> render_many(AccountView, "show.json", opts) |> Enum.filter(&Enum.any?/1) @@ -53,7 +56,7 @@ def render( follow_state = if following_relationships do user_to_target_following_relation = - find_following_rel(following_relationships, reading_user, target) + FollowingRelationship.find(following_relationships, reading_user, target) User.get_follow_state(reading_user, target, user_to_target_following_relation) else @@ -62,7 +65,7 @@ def render( followed_by = if following_relationships do - case find_following_rel(following_relationships, target, reading_user) do + case FollowingRelationship.find(following_relationships, target, reading_user) do %{state: "accept"} -> true _ -> false end @@ -70,7 +73,7 @@ def render( User.following?(target, reading_user) end - # NOTE: adjust StatusView.relationships_opts/2 if adding new relation-related flags + # NOTE: adjust UserRelationship.view_relationships_option/2 on new relation-related flags %{ id: to_string(target.id), following: follow_state == "accept", @@ -129,11 +132,16 @@ def render( } end - def render("relationships.json", %{user: user, targets: targets}) do - relationships_opts = StatusView.relationships_opts(user, targets) - opts = %{as: :target, user: user, relationships: relationships_opts} + def render("relationships.json", %{user: user, targets: targets} = opts) do + relationships_opt = + if Map.has_key?(opts, :relationships) do + opts[:relationships] + else + UserRelationship.view_relationships_option(user, targets) + end - render_many(targets, AccountView, "relationship.json", opts) + render_opts = %{as: :target, user: user, relationships: relationships_opt} + render_many(targets, AccountView, "relationship.json", render_opts) end defp do_render("show.json", %{user: user} = opts) do diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index e9c618496..db434271c 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -8,12 +8,13 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do alias Pleroma.Activity alias Pleroma.Notification alias Pleroma.User + alias Pleroma.UserRelationship alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView - def render("index.json", %{notifications: notifications, for: reading_user}) do + def render("index.json", %{notifications: notifications, for: reading_user} = opts) do activities = Enum.map(notifications, & &1.activity) parent_activities = @@ -30,21 +31,28 @@ def render("index.json", %{notifications: notifications, for: reading_user}) do |> Activity.with_preloaded_object(:left) |> Pleroma.Repo.all() - move_activities_targets = - activities - |> Enum.filter(&(Activity.mastodon_notification_type(&1) == "move")) - |> Enum.map(&User.get_cached_by_ap_id(&1.data["target"])) + relationships_opt = + if Map.has_key?(opts, :relationships) do + opts[:relationships] + else + move_activities_targets = + activities + |> Enum.filter(&(Activity.mastodon_notification_type(&1) == "move")) + |> Enum.map(&User.get_cached_by_ap_id(&1.data["target"])) - actors = - activities - |> Enum.map(fn a -> User.get_cached_by_ap_id(a.data["actor"]) end) - |> Enum.filter(& &1) - |> Kernel.++(move_activities_targets) + actors = + activities + |> Enum.map(fn a -> User.get_cached_by_ap_id(a.data["actor"]) end) + |> Enum.filter(& &1) + |> Kernel.++(move_activities_targets) + + UserRelationship.view_relationships_option(reading_user, actors) + end opts = %{ for: reading_user, parent_activities: parent_activities, - relationships: StatusView.relationships_opts(reading_user, actors) + relationships: relationships_opt } safe_render_many(notifications, NotificationView, "show.json", opts) @@ -85,27 +93,27 @@ def render( } } - relationships_opts = %{relationships: opts[:relationships]} + relationships_opt = %{relationships: opts[:relationships]} case mastodon_type do "mention" -> - put_status(response, activity, reading_user, relationships_opts) + put_status(response, activity, reading_user, relationships_opt) "favourite" -> - put_status(response, parent_activity_fn.(), reading_user, relationships_opts) + put_status(response, parent_activity_fn.(), reading_user, relationships_opt) "reblog" -> - put_status(response, parent_activity_fn.(), reading_user, relationships_opts) + put_status(response, parent_activity_fn.(), reading_user, relationships_opt) "move" -> - put_target(response, activity, reading_user, relationships_opts) + put_target(response, activity, reading_user, relationships_opt) "follow" -> response "pleroma:emoji_reaction" -> response - |> put_status(parent_activity_fn.(), reading_user, relationships_opts) + |> put_status(parent_activity_fn.(), reading_user, relationships_opt) |> put_emoji(activity) _ -> diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 0ef65b352..7b1cb7bf8 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -9,7 +9,6 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do alias Pleroma.Activity alias Pleroma.ActivityExpiration - alias Pleroma.FollowingRelationship alias Pleroma.HTML alias Pleroma.Object alias Pleroma.Repo @@ -72,24 +71,6 @@ defp reblogged?(activity, user) do present?(user && user.ap_id in (object.data["announcements"] || [])) end - def relationships_opts(_reading_user = nil, _actors) do - %{user_relationships: [], following_relationships: []} - end - - def relationships_opts(reading_user, actors) do - user_relationships = - UserRelationship.dictionary( - [reading_user], - actors, - [:block, :mute, :notification_mute, :reblog_mute], - [:block, :inverse_subscription] - ) - - following_relationships = FollowingRelationship.all_between_user_sets([reading_user], actors) - - %{user_relationships: user_relationships, following_relationships: following_relationships} - end - def render("index.json", opts) do # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list activities = Enum.filter(opts.activities, & &1) @@ -105,13 +86,19 @@ def render("index.json", opts) do |> Activity.with_set_thread_muted_field(opts[:for]) |> Repo.all() - actors = Enum.map(activities ++ parent_activities, &get_user(&1.data["actor"])) + relationships_opt = + if Map.has_key?(opts, :relationships) do + opts[:relationships] + else + actors = Enum.map(activities ++ parent_activities, &get_user(&1.data["actor"])) + UserRelationship.view_relationships_option(opts[:for], actors) + end opts = opts |> Map.put(:replied_to_activities, replied_to_activities) |> Map.put(:parent_activities, parent_activities) - |> Map.put(:relationships, relationships_opts(opts[:for], actors)) + |> Map.put(:relationships, relationships_opt) safe_render_many(activities, StatusView, "show.json", opts) end diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 983886c6b..ede62903f 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -4,8 +4,11 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do use Pleroma.DataCase + import Pleroma.Factory + alias Pleroma.User + alias Pleroma.UserRelationship alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView @@ -182,6 +185,29 @@ test "Represent a smaller mention" do end describe "relationship" do + defp test_relationship_rendering(user, other_user, expected_result) do + opts = %{user: user, target: other_user} + assert expected_result == AccountView.render("relationship.json", opts) + + relationships_opt = UserRelationship.view_relationships_option(user, [other_user]) + opts = Map.put(opts, :relationships, relationships_opt) + assert expected_result == AccountView.render("relationship.json", opts) + end + + @blank_response %{ + following: false, + followed_by: false, + blocking: false, + blocked_by: false, + muting: false, + muting_notifications: false, + subscribing: false, + requested: false, + domain_blocking: false, + showing_reblogs: true, + endorsed: false + } + test "represent a relationship for the following and followed user" do user = insert(:user) other_user = insert(:user) @@ -192,23 +218,21 @@ test "represent a relationship for the following and followed user" do {:ok, _user_relationships} = User.mute(user, other_user, true) {:ok, _reblog_mute} = CommonAPI.hide_reblogs(user, other_user) - expected = %{ - id: to_string(other_user.id), - following: true, - followed_by: true, - blocking: false, - blocked_by: false, - muting: true, - muting_notifications: true, - subscribing: true, - requested: false, - domain_blocking: false, - showing_reblogs: false, - endorsed: false - } + expected = + Map.merge( + @blank_response, + %{ + following: true, + followed_by: true, + muting: true, + muting_notifications: true, + subscribing: true, + showing_reblogs: false, + id: to_string(other_user.id) + } + ) - assert expected == - AccountView.render("relationship.json", %{user: user, target: other_user}) + test_relationship_rendering(user, other_user, expected) end test "represent a relationship for the blocking and blocked user" do @@ -220,23 +244,13 @@ test "represent a relationship for the blocking and blocked user" do {:ok, _user_relationship} = User.block(user, other_user) {:ok, _user_relationship} = User.block(other_user, user) - expected = %{ - id: to_string(other_user.id), - following: false, - followed_by: false, - blocking: true, - blocked_by: true, - muting: false, - muting_notifications: false, - subscribing: false, - requested: false, - domain_blocking: false, - showing_reblogs: true, - endorsed: false - } + expected = + Map.merge( + @blank_response, + %{following: false, blocking: true, blocked_by: true, id: to_string(other_user.id)} + ) - assert expected == - AccountView.render("relationship.json", %{user: user, target: other_user}) + test_relationship_rendering(user, other_user, expected) end test "represent a relationship for the user blocking a domain" do @@ -245,8 +259,13 @@ test "represent a relationship for the user blocking a domain" do {:ok, user} = User.block_domain(user, "bad.site") - assert %{domain_blocking: true, blocking: false} = - AccountView.render("relationship.json", %{user: user, target: other_user}) + expected = + Map.merge( + @blank_response, + %{domain_blocking: true, blocking: false, id: to_string(other_user.id)} + ) + + test_relationship_rendering(user, other_user, expected) end test "represent a relationship for the user with a pending follow request" do @@ -257,23 +276,13 @@ test "represent a relationship for the user with a pending follow request" do user = User.get_cached_by_id(user.id) other_user = User.get_cached_by_id(other_user.id) - expected = %{ - id: to_string(other_user.id), - following: false, - followed_by: false, - blocking: false, - blocked_by: false, - muting: false, - muting_notifications: false, - subscribing: false, - requested: true, - domain_blocking: false, - showing_reblogs: true, - endorsed: false - } + expected = + Map.merge( + @blank_response, + %{requested: true, following: false, id: to_string(other_user.id)} + ) - assert expected == - AccountView.render("relationship.json", %{user: user, target: other_user}) + test_relationship_rendering(user, other_user, expected) end end diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index d04c3022f..7965af00a 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -16,6 +16,21 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do alias Pleroma.Web.MastodonAPI.StatusView import Pleroma.Factory + defp test_notifications_rendering(notifications, user, expected_result) do + result = NotificationView.render("index.json", %{notifications: notifications, for: user}) + + assert expected_result == result + + result = + NotificationView.render("index.json", %{ + notifications: notifications, + for: user, + relationships: nil + }) + + assert expected_result == result + end + test "Mention notification" do user = insert(:user) mentioned_user = insert(:user) @@ -32,10 +47,7 @@ test "Mention notification" do created_at: Utils.to_masto_date(notification.inserted_at) } - result = - NotificationView.render("index.json", %{notifications: [notification], for: mentioned_user}) - - assert [expected] == result + test_notifications_rendering([notification], mentioned_user, [expected]) end test "Favourite notification" do @@ -55,9 +67,7 @@ test "Favourite notification" do created_at: Utils.to_masto_date(notification.inserted_at) } - result = NotificationView.render("index.json", %{notifications: [notification], for: user}) - - assert [expected] == result + test_notifications_rendering([notification], user, [expected]) end test "Reblog notification" do @@ -77,9 +87,7 @@ test "Reblog notification" do created_at: Utils.to_masto_date(notification.inserted_at) } - result = NotificationView.render("index.json", %{notifications: [notification], for: user}) - - assert [expected] == result + test_notifications_rendering([notification], user, [expected]) end test "Follow notification" do @@ -96,16 +104,12 @@ test "Follow notification" do created_at: Utils.to_masto_date(notification.inserted_at) } - result = - NotificationView.render("index.json", %{notifications: [notification], for: followed}) - - assert [expected] == result + test_notifications_rendering([notification], followed, [expected]) User.perform(:delete, follower) notification = Notification |> Repo.one() |> Repo.preload(:activity) - assert [] == - NotificationView.render("index.json", %{notifications: [notification], for: followed}) + test_notifications_rendering([notification], followed, []) end test "Move notification" do @@ -131,8 +135,7 @@ test "Move notification" do created_at: Utils.to_masto_date(notification.inserted_at) } - assert [expected] == - NotificationView.render("index.json", %{notifications: [notification], for: follower}) + test_notifications_rendering([notification], follower, [expected]) end test "EmojiReact notification" do @@ -158,7 +161,6 @@ test "EmojiReact notification" do created_at: Utils.to_masto_date(notification.inserted_at) } - assert expected == - NotificationView.render("show.json", %{notification: notification, for: user}) + test_notifications_rendering([notification], user, [expected]) 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 191895c6f..9191730cd 100644 --- a/test/web/mastodon_api/views/status_view_test.exs +++ b/test/web/mastodon_api/views/status_view_test.exs @@ -12,10 +12,12 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User + alias Pleroma.UserRelationship alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.StatusView + import Pleroma.Factory import Tesla.Mock @@ -212,12 +214,21 @@ test "tells if the message is muted for some reason" do {:ok, _user_relationships} = User.mute(user, other_user) {:ok, activity} = CommonAPI.post(other_user, %{"status" => "test"}) - status = StatusView.render("show.json", %{activity: activity}) + relationships_opt = UserRelationship.view_relationships_option(user, [other_user]) + + opts = %{activity: activity} + status = StatusView.render("show.json", opts) assert status.muted == false - status = StatusView.render("show.json", %{activity: activity, for: user}) + status = StatusView.render("show.json", Map.put(opts, :relationships, relationships_opt)) + assert status.muted == false + for_opts = %{activity: activity, for: user} + status = StatusView.render("show.json", for_opts) + assert status.muted == true + + status = StatusView.render("show.json", Map.put(for_opts, :relationships, relationships_opt)) assert status.muted == true end From 4cf1007a7d478a54a759d018dd7ce958a45f3977 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 26 Mar 2020 15:16:54 +0100 Subject: [PATCH 118/188] ActivityPub: Small refactor. --- lib/pleroma/web/activity_pub/activity_pub.ex | 23 ++++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index dd4b04185..35c2eb133 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -129,18 +129,17 @@ def increase_poll_votes_if_vote(_create_data), do: :noop # TODO rewrite in with style @spec persist(map(), keyword()) :: {:ok, Activity.t() | Object.t()} def persist(object, meta) do - local = Keyword.fetch!(meta, :local) - {recipients, _, _} = get_recipients(object) - - {:ok, activity} = - Repo.insert(%Activity{ - data: object, - local: local, - recipients: recipients, - actor: object["actor"] - }) - - {:ok, activity, meta} + with local <- Keyword.fetch!(meta, :local), + {recipients, _, _} <- get_recipients(object), + {:ok, activity} <- + Repo.insert(%Activity{ + data: object, + local: local, + recipients: recipients, + actor: object["actor"] + }) do + {:ok, activity, meta} + end end def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when is_map(map) do From d7aa0b645b0da48af830f252ae80458afc965281 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 26 Mar 2020 14:23:19 +0000 Subject: [PATCH 119/188] Apply suggestion to lib/pleroma/web/activity_pub/object_validator.ex --- lib/pleroma/web/activity_pub/object_validator.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index cff924047..9b2889e92 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -26,8 +26,7 @@ def validate(%{"type" => "Like"} = object, meta) do def stringify_keys(object) do object - |> Enum.map(fn {key, val} -> {to_string(key), val} end) - |> Enum.into(%{}) + |> Map.new(fn {key, val} -> {to_string(key), val} end) end def fetch_actor_and_object(object) do From eaacc648392e6544cd3a3b77bde266e34cebf634 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 26 Mar 2020 15:33:10 +0100 Subject: [PATCH 120/188] Refactors. --- lib/pleroma/web/activity_pub/activity_pub.ex | 3 +-- .../activity_pub/object_validators/common_validations.ex | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 35c2eb133..55f4de693 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -125,8 +125,6 @@ def increase_poll_votes_if_vote(%{ def increase_poll_votes_if_vote(_create_data), do: :noop - @spec insert(map(), boolean(), boolean(), boolean()) :: {:ok, Activity.t()} | {:error, any()} - # TODO rewrite in with style @spec persist(map(), keyword()) :: {:ok, Activity.t() | Object.t()} def persist(object, meta) do with local <- Keyword.fetch!(meta, :local), @@ -142,6 +140,7 @@ def persist(object, meta) do end end + @spec insert(map(), boolean(), boolean(), boolean()) :: {:ok, Activity.t()} | {:error, any()} def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when is_map(map) do with nil <- Activity.normalize(map), map <- lazy_put_activity_defaults(map, fake), 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 db0e2072d..26a57f02b 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -21,11 +21,11 @@ def validate_actor_presence(cng, field_name \\ :actor) do def validate_object_presence(cng, field_name \\ :object) do cng - |> validate_change(field_name, fn field_name, actor -> - if Object.get_cached_by_ap_id(actor) do + |> validate_change(field_name, fn field_name, object -> + if Object.get_cached_by_ap_id(object) do [] else - [{field_name, "can't find user"}] + [{field_name, "can't find object"}] end end) end From 0adaab8e753b0ec22feccfc03d301073327a6d31 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 26 Mar 2020 15:37:42 +0100 Subject: [PATCH 121/188] Bump copyright dates. --- COPYING | 4 ++-- lib/pleroma/web/activity_pub/object_validator.ex | 2 +- .../web/activity_pub/object_validators/common_validations.ex | 2 +- .../web/activity_pub/object_validators/create_validator.ex | 2 +- .../web/activity_pub/object_validators/like_validator.ex | 2 +- .../web/activity_pub/object_validators/note_validator.ex | 2 +- lib/pleroma/web/activity_pub/pipeline.ex | 2 +- priv/repo/migrations/20190408123347_create_conversations.exs | 2 +- .../activity_pub/object_validators/note_validator_test.exs | 2 +- test/web/activity_pub/pipeline_test.exs | 2 +- test/web/activity_pub/side_effects_test.exs | 2 +- 11 files changed, 12 insertions(+), 12 deletions(-) diff --git a/COPYING b/COPYING index 0aede0fba..3140c8038 100644 --- a/COPYING +++ b/COPYING @@ -1,4 +1,4 @@ -Unless otherwise stated this repository is copyright © 2017-2019 +Unless otherwise stated this repository is copyright © 2017-2020 Pleroma Authors , and is distributed under The GNU Affero General Public License Version 3, you should have received a copy of the license file as AGPL-3. @@ -23,7 +23,7 @@ priv/static/images/pleroma-fox-tan-shy.png --- -The following files are copyright © 2017-2019 Pleroma Authors +The following files are copyright © 2017-2020 Pleroma Authors , and are distributed under the Creative Commons Attribution-ShareAlike 4.0 International license, you should have received a copy of the license file as CC-BY-SA-4.0. diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 9b2889e92..dc4bce059 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ObjectValidator do 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 26a57f02b..b479c3918 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do diff --git a/lib/pleroma/web/activity_pub/object_validators/create_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_validator.ex index 872a12c48..908381981 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_validator.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateNoteValidator do 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 ccbc7d071..2c1d38b06 100644 --- a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator do diff --git a/lib/pleroma/web/activity_pub/object_validators/note_validator.ex b/lib/pleroma/web/activity_pub/object_validators/note_validator.ex index eea15ce1c..fc65f1b7c 100644 --- a/lib/pleroma/web/activity_pub/object_validators/note_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/note_validator.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator do diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 25f29bf63..eed53cd34 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.Pipeline do diff --git a/priv/repo/migrations/20190408123347_create_conversations.exs b/priv/repo/migrations/20190408123347_create_conversations.exs index d75459e82..3eaa6136c 100644 --- a/priv/repo/migrations/20190408123347_create_conversations.exs +++ b/priv/repo/migrations/20190408123347_create_conversations.exs @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Repo.Migrations.CreateConversations do diff --git a/test/web/activity_pub/object_validators/note_validator_test.exs b/test/web/activity_pub/object_validators/note_validator_test.exs index 2bcd75e25..30c481ffb 100644 --- a/test/web/activity_pub/object_validators/note_validator_test.exs +++ b/test/web/activity_pub/object_validators/note_validator_test.exs @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidatorTest do diff --git a/test/web/activity_pub/pipeline_test.exs b/test/web/activity_pub/pipeline_test.exs index 318d306af..f3c437498 100644 --- a/test/web/activity_pub/pipeline_test.exs +++ b/test/web/activity_pub/pipeline_test.exs @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.PipelineTest do diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index ef91954ae..b67bd14b3 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.SideEffectsTest do From 0c60c0a76a2fcc8d13992b51704c21a35da10a0b Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 26 Mar 2020 15:44:14 +0100 Subject: [PATCH 122/188] Validators: Use correct type for IDs. --- .../web/activity_pub/object_validators/create_validator.ex | 2 +- .../web/activity_pub/object_validators/like_validator.ex | 2 +- .../web/activity_pub/object_validators/note_validator.ex | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/create_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_validator.ex index 908381981..926804ce7 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_validator.ex @@ -13,7 +13,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateNoteValidator do @primary_key false embedded_schema do - field(:id, :string, primary_key: true) + field(:id, Types.ObjectID, primary_key: true) field(:actor, Types.ObjectID) field(:type, :string) field(:to, {:array, :string}) 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 2c1d38b06..49546ceaa 100644 --- a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex @@ -14,7 +14,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator do @primary_key false embedded_schema do - field(:id, :string, primary_key: true) + field(:id, Types.ObjectID, primary_key: true) field(:type, :string) field(:object, Types.ObjectID) field(:actor, Types.ObjectID) diff --git a/lib/pleroma/web/activity_pub/object_validators/note_validator.ex b/lib/pleroma/web/activity_pub/object_validators/note_validator.ex index fc65f1b7c..c95b622e4 100644 --- a/lib/pleroma/web/activity_pub/object_validators/note_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/note_validator.ex @@ -12,7 +12,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator do @primary_key false embedded_schema do - field(:id, :string, primary_key: true) + field(:id, Types.ObjectID, primary_key: true) field(:to, {:array, :string}, default: []) field(:cc, {:array, :string}, default: []) field(:bto, {:array, :string}, default: []) From 69fc1dd69ff9d63af1785bb0701576cb5cde51f2 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 26 Mar 2020 14:45:28 +0000 Subject: [PATCH 123/188] Apply suggestion to lib/pleroma/web/activity_pub/pipeline.ex --- lib/pleroma/web/activity_pub/pipeline.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 25f29bf63..0068d60be 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -22,6 +22,7 @@ def common_pipeline(object, meta) do {_, {:ok, _}} <- {:federation, maybe_federate(activity, meta)} do {:ok, activity, meta} else + {:mrf_object, {:reject, _}} -> {:ok, nil, meta} e -> {:error, e} end end From 6b793d3f8336fcba5cac596f9e76d0274633f98d Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 26 Mar 2020 21:54:01 +0300 Subject: [PATCH 124/188] Ensured no auxiliary computations (actors list preparation etc.) related to relationships preloading if no user is present (for statuses / accounts / relationships rendering). --- .../web/mastodon_api/views/account_view.ex | 26 +++++++++++----- .../mastodon_api/views/notification_view.ex | 31 +++++++++++-------- .../web/mastodon_api/views/status_view.ex | 16 +++++++--- 3 files changed, 47 insertions(+), 26 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 2cdfac7af..0efcabc01 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -14,10 +14,15 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do def render("index.json", %{users: users} = opts) do relationships_opt = - if Map.has_key?(opts, :relationships) do - opts[:relationships] - else - UserRelationship.view_relationships_option(opts[:for], users) + cond do + Map.has_key?(opts, :relationships) -> + opts[:relationships] + + is_nil(opts[:for]) -> + UserRelationship.view_relationships_option(nil, []) + + true -> + UserRelationship.view_relationships_option(opts[:for], users) end opts = Map.put(opts, :relationships, relationships_opt) @@ -134,10 +139,15 @@ def render( def render("relationships.json", %{user: user, targets: targets} = opts) do relationships_opt = - if Map.has_key?(opts, :relationships) do - opts[:relationships] - else - UserRelationship.view_relationships_option(user, targets) + cond do + Map.has_key?(opts, :relationships) -> + opts[:relationships] + + is_nil(opts[:for]) -> + UserRelationship.view_relationships_option(nil, []) + + true -> + UserRelationship.view_relationships_option(user, targets) end render_opts = %{as: :target, user: user, relationships: relationships_opt} diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index db434271c..a809080fd 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -32,21 +32,26 @@ def render("index.json", %{notifications: notifications, for: reading_user} = op |> Pleroma.Repo.all() relationships_opt = - if Map.has_key?(opts, :relationships) do - opts[:relationships] - else - move_activities_targets = - activities - |> Enum.filter(&(Activity.mastodon_notification_type(&1) == "move")) - |> Enum.map(&User.get_cached_by_ap_id(&1.data["target"])) + cond do + Map.has_key?(opts, :relationships) -> + opts[:relationships] - actors = - activities - |> Enum.map(fn a -> User.get_cached_by_ap_id(a.data["actor"]) end) - |> Enum.filter(& &1) - |> Kernel.++(move_activities_targets) + is_nil(opts[:for]) -> + UserRelationship.view_relationships_option(nil, []) - UserRelationship.view_relationships_option(reading_user, actors) + true -> + move_activities_targets = + activities + |> Enum.filter(&(Activity.mastodon_notification_type(&1) == "move")) + |> Enum.map(&User.get_cached_by_ap_id(&1.data["target"])) + + actors = + activities + |> Enum.map(fn a -> User.get_cached_by_ap_id(a.data["actor"]) end) + |> Enum.filter(& &1) + |> Kernel.++(move_activities_targets) + + UserRelationship.view_relationships_option(reading_user, actors) end opts = %{ diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 7b1cb7bf8..d36b9ee5c 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -87,11 +87,17 @@ def render("index.json", opts) do |> Repo.all() relationships_opt = - if Map.has_key?(opts, :relationships) do - opts[:relationships] - else - actors = Enum.map(activities ++ parent_activities, &get_user(&1.data["actor"])) - UserRelationship.view_relationships_option(opts[:for], actors) + cond do + Map.has_key?(opts, :relationships) -> + opts[:relationships] + + is_nil(opts[:for]) -> + UserRelationship.view_relationships_option(nil, []) + + true -> + actors = Enum.map(activities ++ parent_activities, &get_user(&1.data["actor"])) + + UserRelationship.view_relationships_option(opts[:for], actors) end opts = From dfbc05d4965a04a82d4c4c5b8842f4117757f30e Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 27 Mar 2020 08:01:03 +0300 Subject: [PATCH 125/188] Misc refactoring / tweaks (`ThreadMute.exists?/2`). --- lib/pleroma/thread_mute.ex | 4 ++-- lib/pleroma/web/common_api/common_api.ex | 2 +- .../web/mastodon_api/views/notification_view.ex | 12 ++++++------ lib/pleroma/web/mastodon_api/views/status_view.ex | 7 ++++--- test/web/mastodon_api/views/account_view_test.exs | 2 +- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/thread_mute.ex b/lib/pleroma/thread_mute.ex index 5768e7711..be01d541d 100644 --- a/lib/pleroma/thread_mute.ex +++ b/lib/pleroma/thread_mute.ex @@ -68,8 +68,8 @@ def remove_mute(user_id, context) do |> Repo.delete_all() end - def check_muted(user_id, context) do + def exists?(user_id, context) do query(user_id, context) - |> Repo.all() + |> Repo.exists?() end end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 091011c6b..2646b9f7b 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -358,7 +358,7 @@ def remove_mute(user, activity) do def thread_muted?(%{id: nil} = _user, _activity), do: false def thread_muted?(user, activity) do - ThreadMute.check_muted(user.id, activity.data["context"]) != [] + ThreadMute.exists?(user.id, activity.data["context"]) end def report(user, %{"account_id" => account_id} = data) do diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index a809080fd..89f5734ff 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -98,27 +98,27 @@ def render( } } - relationships_opt = %{relationships: opts[:relationships]} + render_opts = %{relationships: opts[:relationships]} case mastodon_type do "mention" -> - put_status(response, activity, reading_user, relationships_opt) + put_status(response, activity, reading_user, render_opts) "favourite" -> - put_status(response, parent_activity_fn.(), reading_user, relationships_opt) + put_status(response, parent_activity_fn.(), reading_user, render_opts) "reblog" -> - put_status(response, parent_activity_fn.(), reading_user, relationships_opt) + put_status(response, parent_activity_fn.(), reading_user, render_opts) "move" -> - put_target(response, activity, reading_user, relationships_opt) + put_target(response, activity, reading_user, render_opts) "follow" -> response "pleroma:emoji_reaction" -> response - |> put_status(parent_activity_fn.(), reading_user, relationships_opt) + |> put_status(parent_activity_fn.(), reading_user, render_opts) |> put_emoji(activity) _ -> diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index d36b9ee5c..440eef4ba 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -228,9 +228,10 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} end thread_muted? = - case activity.thread_muted? do - thread_muted? when is_boolean(thread_muted?) -> thread_muted? - nil -> (opts[:for] && CommonAPI.thread_muted?(opts[:for], activity)) || false + cond do + is_nil(opts[:for]) -> false + is_boolean(activity.thread_muted?) -> activity.thread_muted? + true -> CommonAPI.thread_muted?(opts[:for], activity) end attachment_data = object.data["attachment"] || [] diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index ede62903f..0d1c3ecb3 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -186,7 +186,7 @@ test "Represent a smaller mention" do describe "relationship" do defp test_relationship_rendering(user, other_user, expected_result) do - opts = %{user: user, target: other_user} + opts = %{user: user, target: other_user, relationships: nil} assert expected_result == AccountView.render("relationship.json", opts) relationships_opt = UserRelationship.view_relationships_option(user, [other_user]) From eb9744cadea7191b088ddaadfbd5fa4d4fd45090 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 14 Jan 2020 14:42:30 +0300 Subject: [PATCH 126/188] activities generation tasks --- benchmarks/load_testing/activities.ex | 515 ++++++++++++++ benchmarks/load_testing/fetcher.ex | 709 ++++++++++++------- benchmarks/load_testing/generator.ex | 410 ----------- benchmarks/load_testing/helper.ex | 10 +- benchmarks/load_testing/users.ex | 161 +++++ benchmarks/mix/tasks/pleroma/load_testing.ex | 136 +--- config/benchmark.exs | 2 +- lib/pleroma/application.ex | 2 +- 8 files changed, 1184 insertions(+), 761 deletions(-) create mode 100644 benchmarks/load_testing/activities.ex delete mode 100644 benchmarks/load_testing/generator.ex create mode 100644 benchmarks/load_testing/users.ex diff --git a/benchmarks/load_testing/activities.ex b/benchmarks/load_testing/activities.ex new file mode 100644 index 000000000..db0e5a66f --- /dev/null +++ b/benchmarks/load_testing/activities.ex @@ -0,0 +1,515 @@ +defmodule Pleroma.LoadTesting.Activities do + @moduledoc """ + Module for generating different activities. + """ + import Ecto.Query + import Pleroma.LoadTesting.Helper, only: [to_sec: 1] + + alias Ecto.UUID + alias Pleroma.Constants + alias Pleroma.LoadTesting.Users + alias Pleroma.Repo + alias Pleroma.Web.CommonAPI + + require Constants + + @defaults [ + iterations: 170, + friends_used: 20, + non_friends_used: 20 + ] + + @max_concurrency 30 + + @visibility ~w(public private direct unlisted) + @types ~w(simple emoji mentions hell_thread attachment tag like reblog simple_thread remote) + @groups ~w(user friends non_friends) + + @spec generate(User.t(), keyword()) :: :ok + def generate(user, opts \\ []) do + {:ok, _} = + Agent.start_link(fn -> %{} end, + name: :benchmark_state + ) + + opts = Keyword.merge(@defaults, opts) + + friends = + user + |> Users.get_users(limit: opts[:friends_used], local: :local, friends?: true) + |> Enum.shuffle() + + non_friends = + user + |> Users.get_users(limit: opts[:non_friends_used], local: :local, friends?: false) + |> Enum.shuffle() + + task_data = + for visibility <- @visibility, + type <- @types, + group <- @groups, + do: {visibility, type, group} + + IO.puts("Starting generating #{opts[:iterations]} iterations of activities...") + + friends_thread = Enum.take(friends, 5) + non_friends_thread = Enum.take(friends, 5) + + public_long_thread = fn -> + generate_long_thread("public", user, friends_thread, non_friends_thread, opts) + end + + private_long_thread = fn -> + generate_long_thread("private", user, friends_thread, non_friends_thread, opts) + end + + iterations = opts[:iterations] + + {time, _} = + :timer.tc(fn -> + Enum.each( + 1..iterations, + fn + i when i == iterations - 2 -> + spawn(public_long_thread) + spawn(private_long_thread) + generate_activities(user, friends, non_friends, Enum.shuffle(task_data), opts) + + _ -> + generate_activities(user, friends, non_friends, Enum.shuffle(task_data), opts) + end + ) + end) + + IO.puts("Generating iterations activities take #{to_sec(time)} sec.\n") + :ok + end + + defp generate_long_thread(visibility, user, friends, non_friends, _opts) do + group = + if visibility == "public", + do: "friends", + else: "user" + + tasks = get_reply_tasks(visibility, group) |> Stream.cycle() |> Enum.take(50) + + {:ok, activity} = + CommonAPI.post(user, %{ + "status" => "Start of #{visibility} long thread", + "visibility" => visibility + }) + + Agent.update(:benchmark_state, fn state -> + key = + if visibility == "public", + do: :public_thread, + else: :private_thread + + Map.put(state, key, activity) + end) + + acc = {activity.id, ["@" <> user.nickname, "reply to long thread"]} + insert_replies_for_long_thread(tasks, visibility, user, friends, non_friends, acc) + IO.puts("Generating #{visibility} long thread ended\n") + end + + defp insert_replies_for_long_thread(tasks, visibility, user, friends, non_friends, acc) do + Enum.reduce(tasks, acc, fn + "friend", {id, data} -> + friend = Enum.random(friends) + insert_reply(friend, List.delete(data, "@" <> friend.nickname), id, visibility) + + "non_friend", {id, data} -> + non_friend = Enum.random(non_friends) + insert_reply(non_friend, List.delete(data, "@" <> non_friend.nickname), id, visibility) + + "user", {id, data} -> + insert_reply(user, List.delete(data, "@" <> user.nickname), id, visibility) + end) + end + + defp generate_activities(user, friends, non_friends, task_data, opts) do + Task.async_stream( + task_data, + fn {visibility, type, group} -> + insert_activity(type, visibility, group, user, friends, non_friends, opts) + end, + max_concurrency: @max_concurrency, + timeout: 30_000 + ) + |> Stream.run() + end + + defp insert_activity("simple", visibility, group, user, friends, non_friends, _opts) do + {:ok, _activity} = + group + |> get_actor(user, friends, non_friends) + |> CommonAPI.post(%{"status" => "Simple status", "visibility" => visibility}) + end + + defp insert_activity("emoji", visibility, group, user, friends, non_friends, _opts) do + {:ok, _activity} = + group + |> get_actor(user, friends, non_friends) + |> CommonAPI.post(%{ + "status" => "Simple status with emoji :firefox:", + "visibility" => visibility + }) + end + + defp insert_activity("mentions", visibility, group, user, friends, non_friends, _opts) do + user_mentions = + get_random_mentions(friends, Enum.random(0..3)) ++ + get_random_mentions(non_friends, Enum.random(0..3)) + + user_mentions = + if Enum.random([true, false]), + do: ["@" <> user.nickname | user_mentions], + else: user_mentions + + {:ok, _activity} = + group + |> get_actor(user, friends, non_friends) + |> CommonAPI.post(%{ + "status" => Enum.join(user_mentions, ", ") <> " simple status with mentions", + "visibility" => visibility + }) + end + + defp insert_activity("hell_thread", visibility, group, user, friends, non_friends, _opts) do + mentions = + with {:ok, nil} <- Cachex.get(:user_cache, "hell_thread_mentions") do + cached = + ([user | Enum.take(friends, 10)] ++ Enum.take(non_friends, 10)) + |> Enum.map(&"@#{&1.nickname}") + |> Enum.join(", ") + + Cachex.put(:user_cache, "hell_thread_mentions", cached) + cached + else + {:ok, cached} -> cached + end + + {:ok, _activity} = + group + |> get_actor(user, friends, non_friends) + |> CommonAPI.post(%{ + "status" => mentions <> " hell thread status", + "visibility" => visibility + }) + end + + defp insert_activity("attachment", visibility, group, user, friends, non_friends, _opts) do + actor = get_actor(group, user, friends, non_friends) + + obj_data = %{ + "actor" => actor.ap_id, + "name" => "4467-11.jpg", + "type" => "Document", + "url" => [ + %{ + "href" => + "#{Pleroma.Web.base_url()}/media/b1b873552422a07bf53af01f3c231c841db4dfc42c35efde681abaf0f2a4eab7.jpg", + "mediaType" => "image/jpeg", + "type" => "Link" + } + ] + } + + object = Repo.insert!(%Pleroma.Object{data: obj_data}) + + {:ok, _activity} = + CommonAPI.post(actor, %{ + "status" => "Post with attachment", + "visibility" => visibility, + "media_ids" => [object.id] + }) + end + + defp insert_activity("tag", visibility, group, user, friends, non_friends, _opts) do + {:ok, _activity} = + group + |> get_actor(user, friends, non_friends) + |> CommonAPI.post(%{"status" => "Status with #tag", "visibility" => visibility}) + end + + defp insert_activity("like", visibility, group, user, friends, non_friends, opts) do + actor = get_actor(group, user, friends, non_friends) + + with activity_id when not is_nil(activity_id) <- get_random_create_activity_id(), + {:ok, _activity, _object} <- CommonAPI.favorite(activity_id, actor) do + :ok + else + {:error, _} -> + insert_activity("like", visibility, group, user, friends, non_friends, opts) + + nil -> + Process.sleep(15) + insert_activity("like", visibility, group, user, friends, non_friends, opts) + end + end + + defp insert_activity("reblog", visibility, group, user, friends, non_friends, opts) do + actor = get_actor(group, user, friends, non_friends) + + with activity_id when not is_nil(activity_id) <- get_random_create_activity_id(), + {:ok, _activity, _object} <- CommonAPI.repeat(activity_id, actor) do + :ok + else + {:error, _} -> + insert_activity("reblog", visibility, group, user, friends, non_friends, opts) + + nil -> + Process.sleep(15) + insert_activity("reblog", visibility, group, user, friends, non_friends, opts) + end + end + + defp insert_activity("simple_thread", visibility, group, user, friends, non_friends, _opts) + when visibility in ["public", "unlisted", "private"] do + actor = get_actor(group, user, friends, non_friends) + tasks = get_reply_tasks(visibility, group) + + {:ok, activity} = + CommonAPI.post(user, %{"status" => "Simple status", "visibility" => "unlisted"}) + + acc = {activity.id, ["@" <> actor.nickname, "reply to status"]} + insert_replies(tasks, visibility, user, friends, non_friends, acc) + end + + defp insert_activity("simple_thread", "direct", group, user, friends, non_friends, _opts) do + actor = get_actor(group, user, friends, non_friends) + tasks = get_reply_tasks("direct", group) + + list = + case group do + "non_friends" -> + Enum.take(non_friends, 3) + + _ -> + Enum.take(friends, 3) + end + + data = Enum.map(list, &("@" <> &1.nickname)) + + {:ok, activity} = + CommonAPI.post(actor, %{ + "status" => Enum.join(data, ", ") <> "simple status", + "visibility" => "direct" + }) + + acc = {activity.id, ["@" <> user.nickname | data] ++ ["reply to status"]} + insert_direct_replies(tasks, user, list, acc) + end + + defp insert_activity("remote", _, "user", _, _, _, _), do: :ok + + defp insert_activity("remote", visibility, group, user, _friends, _non_friends, opts) do + remote_friends = + Users.get_users(user, limit: opts[:friends_used], local: :external, friends?: true) + + remote_non_friends = + Users.get_users(user, limit: opts[:non_friends_used], local: :external, friends?: false) + + actor = get_actor(group, user, remote_friends, remote_non_friends) + + {act_data, obj_data} = prepare_activity_data(actor, visibility, user) + {activity_data, object_data} = other_data(actor) + + activity_data + |> Map.merge(act_data) + |> Map.put("object", Map.merge(object_data, obj_data)) + |> Pleroma.Web.ActivityPub.ActivityPub.insert(false) + end + + defp get_actor("user", user, _friends, _non_friends), do: user + defp get_actor("friends", _user, friends, _non_friends), do: Enum.random(friends) + defp get_actor("non_friends", _user, _friends, non_friends), do: Enum.random(non_friends) + + defp other_data(actor) do + %{host: host} = URI.parse(actor.ap_id) + datetime = DateTime.utc_now() + context_id = "http://#{host}:4000/contexts/#{UUID.generate()}" + activity_id = "http://#{host}:4000/activities/#{UUID.generate()}" + object_id = "http://#{host}:4000/objects/#{UUID.generate()}" + + activity_data = %{ + "actor" => actor.ap_id, + "context" => context_id, + "id" => activity_id, + "published" => datetime, + "type" => "Create", + "directMessage" => false + } + + object_data = %{ + "actor" => actor.ap_id, + "attachment" => [], + "attributedTo" => actor.ap_id, + "bcc" => [], + "bto" => [], + "content" => "Remote post", + "context" => context_id, + "conversation" => context_id, + "emoji" => %{}, + "id" => object_id, + "published" => datetime, + "sensitive" => false, + "summary" => "", + "tag" => [], + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "type" => "Note" + } + + {activity_data, object_data} + end + + defp prepare_activity_data(actor, "public", _mention) do + obj_data = %{ + "cc" => [actor.follower_address], + "to" => [Constants.as_public()] + } + + act_data = %{ + "cc" => [actor.follower_address], + "to" => [Constants.as_public()] + } + + {act_data, obj_data} + end + + defp prepare_activity_data(actor, "private", _mention) do + obj_data = %{ + "cc" => [], + "to" => [actor.follower_address] + } + + act_data = %{ + "cc" => [], + "to" => [actor.follower_address] + } + + {act_data, obj_data} + end + + defp prepare_activity_data(actor, "unlisted", _mention) do + obj_data = %{ + "cc" => [Constants.as_public()], + "to" => [actor.follower_address] + } + + act_data = %{ + "cc" => [Constants.as_public()], + "to" => [actor.follower_address] + } + + {act_data, obj_data} + end + + defp prepare_activity_data(_actor, "direct", mention) do + %{host: mentioned_host} = URI.parse(mention.ap_id) + + obj_data = %{ + "cc" => [], + "content" => + "@#{ + mention.nickname + } direct message", + "tag" => [ + %{ + "href" => mention.ap_id, + "name" => "@#{mention.nickname}@#{mentioned_host}", + "type" => "Mention" + } + ], + "to" => [mention.ap_id] + } + + act_data = %{ + "cc" => [], + "directMessage" => true, + "to" => [mention.ap_id] + } + + {act_data, obj_data} + end + + defp get_reply_tasks("public", "user"), do: ~w(friend non_friend user) + defp get_reply_tasks("public", "friends"), do: ~w(non_friend user friend) + defp get_reply_tasks("public", "non_friends"), do: ~w(user friend non_friend) + + defp get_reply_tasks(visibility, "user") when visibility in ["unlisted", "private"], + do: ~w(friend user friend) + + defp get_reply_tasks(visibility, "friends") when visibility in ["unlisted", "private"], + do: ~w(user friend user) + + defp get_reply_tasks(visibility, "non_friends") when visibility in ["unlisted", "private"], + do: [] + + defp get_reply_tasks("direct", "user"), do: ~w(friend user friend) + defp get_reply_tasks("direct", "friends"), do: ~w(user friend user) + defp get_reply_tasks("direct", "non_friends"), do: ~w(user non_friend user) + + defp insert_replies(tasks, visibility, user, friends, non_friends, acc) do + Enum.reduce(tasks, acc, fn + "friend", {id, data} -> + friend = Enum.random(friends) + insert_reply(friend, data, id, visibility) + + "non_friend", {id, data} -> + non_friend = Enum.random(non_friends) + insert_reply(non_friend, data, id, visibility) + + "user", {id, data} -> + insert_reply(user, data, id, visibility) + end) + end + + defp insert_direct_replies(tasks, user, list, acc) do + Enum.reduce(tasks, acc, fn + group, {id, data} when group in ["friend", "non_friend"] -> + actor = Enum.random(list) + + {reply_id, _} = + insert_reply(actor, List.delete(data, "@" <> actor.nickname), id, "direct") + + {reply_id, data} + + "user", {id, data} -> + {reply_id, _} = insert_reply(user, List.delete(data, "@" <> user.nickname), id, "direct") + {reply_id, data} + end) + end + + defp insert_reply(actor, data, activity_id, visibility) do + {:ok, reply} = + CommonAPI.post(actor, %{ + "status" => Enum.join(data, ", "), + "visibility" => visibility, + "in_reply_to_status_id" => activity_id + }) + + {reply.id, ["@" <> actor.nickname | data]} + end + + defp get_random_mentions(_users, count) when count == 0, do: [] + + defp get_random_mentions(users, count) do + users + |> Enum.shuffle() + |> Enum.take(count) + |> Enum.map(&"@#{&1.nickname}") + end + + defp get_random_create_activity_id do + Repo.one( + from(a in Pleroma.Activity, + where: fragment("(?)->>'type' = ?", a.data, ^"Create"), + order_by: fragment("RANDOM()"), + limit: 1, + select: a.id + ) + ) + end +end diff --git a/benchmarks/load_testing/fetcher.ex b/benchmarks/load_testing/fetcher.ex index a45a71d4a..bd65ac84f 100644 --- a/benchmarks/load_testing/fetcher.ex +++ b/benchmarks/load_testing/fetcher.ex @@ -1,260 +1,489 @@ defmodule Pleroma.LoadTesting.Fetcher do - use Pleroma.LoadTesting.Helper + alias Pleroma.Activity + alias Pleroma.Pagination + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.MastodonAPI.MastodonAPI + alias Pleroma.Web.MastodonAPI.StatusView - def fetch_user(user) do - Benchee.run(%{ - "By id" => fn -> Repo.get_by(User, id: user.id) end, - "By ap_id" => fn -> Repo.get_by(User, ap_id: user.ap_id) end, - "By email" => fn -> Repo.get_by(User, email: user.email) end, - "By nickname" => fn -> Repo.get_by(User, nickname: user.nickname) end - }) + @spec run_benchmarks(User.t()) :: any() + def run_benchmarks(user) do + fetch_user(user) + fetch_timelines(user) + render_views(user) end - def query_timelines(user) do - home_timeline_params = %{ - "count" => 20, - "with_muted" => true, - "type" => ["Create", "Announce"], + defp formatters do + [ + Benchee.Formatters.Console + ] + end + + defp fetch_user(user) do + Benchee.run( + %{ + "By id" => fn -> Repo.get_by(User, id: user.id) end, + "By ap_id" => fn -> Repo.get_by(User, ap_id: user.ap_id) end, + "By email" => fn -> Repo.get_by(User, email: user.email) end, + "By nickname" => fn -> Repo.get_by(User, nickname: user.nickname) end + }, + formatters: formatters() + ) + end + + defp fetch_timelines(user) do + fetch_home_timeline(user) + fetch_direct_timeline(user) + fetch_public_timeline(user) + fetch_public_timeline(user, :local) + fetch_public_timeline(user, :tag) + fetch_notifications(user) + fetch_favourites(user) + fetch_long_thread(user) + end + + defp render_views(user) do + render_timelines(user) + render_long_thread(user) + end + + defp opts_for_home_timeline(user) do + %{ "blocking_user" => user, + "count" => "20", "muting_user" => user, + "type" => ["Create", "Announce"], + "user" => user, + "with_muted" => "true" + } + end + + defp fetch_home_timeline(user) do + opts = opts_for_home_timeline(user) + + recipients = [user.ap_id | User.following(user)] + + first_page_last = + ActivityPub.fetch_activities(recipients, opts) |> Enum.reverse() |> List.last() + + second_page_last = + ActivityPub.fetch_activities(recipients, Map.put(opts, "max_id", first_page_last.id)) + |> Enum.reverse() + |> List.last() + + third_page_last = + ActivityPub.fetch_activities(recipients, Map.put(opts, "max_id", second_page_last.id)) + |> Enum.reverse() + |> List.last() + + forth_page_last = + ActivityPub.fetch_activities(recipients, Map.put(opts, "max_id", third_page_last.id)) + |> Enum.reverse() + |> List.last() + + Benchee.run( + %{ + "home timeline" => fn opts -> ActivityPub.fetch_activities(recipients, opts) end + }, + inputs: %{ + "1 page" => opts, + "2 page" => Map.put(opts, "max_id", first_page_last.id), + "3 page" => Map.put(opts, "max_id", second_page_last.id), + "4 page" => Map.put(opts, "max_id", third_page_last.id), + "5 page" => Map.put(opts, "max_id", forth_page_last.id), + "1 page only media" => Map.put(opts, "only_media", "true"), + "2 page only media" => + Map.put(opts, "max_id", first_page_last.id) |> Map.put("only_media", "true"), + "3 page only media" => + Map.put(opts, "max_id", second_page_last.id) |> Map.put("only_media", "true"), + "4 page only media" => + Map.put(opts, "max_id", third_page_last.id) |> Map.put("only_media", "true"), + "5 page only media" => + Map.put(opts, "max_id", forth_page_last.id) |> Map.put("only_media", "true") + }, + formatters: formatters() + ) + end + + defp opts_for_direct_timeline(user) do + %{ + :visibility => "direct", + "blocking_user" => user, + "count" => "20", + "type" => "Create", + "user" => user, + "with_muted" => "true" + } + end + + defp fetch_direct_timeline(user) do + recipients = [user.ap_id] + + opts = opts_for_direct_timeline(user) + + first_page_last = + recipients + |> ActivityPub.fetch_activities_query(opts) + |> Pagination.fetch_paginated(opts) + |> List.last() + + opts2 = Map.put(opts, "max_id", first_page_last.id) + + second_page_last = + recipients + |> ActivityPub.fetch_activities_query(opts2) + |> Pagination.fetch_paginated(opts2) + |> List.last() + + opts3 = Map.put(opts, "max_id", second_page_last.id) + + third_page_last = + recipients + |> ActivityPub.fetch_activities_query(opts3) + |> Pagination.fetch_paginated(opts3) + |> List.last() + + opts4 = Map.put(opts, "max_id", third_page_last.id) + + forth_page_last = + recipients + |> ActivityPub.fetch_activities_query(opts4) + |> Pagination.fetch_paginated(opts4) + |> List.last() + + Benchee.run( + %{ + "direct timeline" => fn opts -> + ActivityPub.fetch_activities_query(recipients, opts) |> Pagination.fetch_paginated(opts) + end + }, + inputs: %{ + "1 page" => opts, + "2 page" => opts2, + "3 page" => opts3, + "4 page" => opts4, + "5 page" => Map.put(opts4, "max_id", forth_page_last.id) + }, + formatters: formatters() + ) + end + + defp opts_for_public_timeline(user) do + %{ + "type" => ["Create", "Announce"], + "local_only" => false, + "blocking_user" => user, + "muting_user" => user + } + end + + defp opts_for_public_timeline(user, :local) do + %{ + "type" => ["Create", "Announce"], + "local_only" => true, + "blocking_user" => user, + "muting_user" => user + } + end + + defp opts_for_public_timeline(user, :tag) do + %{ + "blocking_user" => user, + "count" => "20", + "local_only" => nil, + "muting_user" => user, + "tag" => ["tag"], + "tag_all" => [], + "tag_reject" => [], + "type" => "Create", + "user" => user, + "with_muted" => "true" + } + end + + defp fetch_public_timeline(user) do + opts = opts_for_public_timeline(user) + + fetch_public_timeline(opts, "public timeline") + end + + defp fetch_public_timeline(user, :local) do + opts = opts_for_public_timeline(user, :local) + + fetch_public_timeline(opts, "public timeline only local") + end + + defp fetch_public_timeline(user, :tag) do + opts = opts_for_public_timeline(user, :tag) + + fetch_public_timeline(opts, "hashtag timeline") + end + + defp fetch_public_timeline(user, :only_media) do + opts = opts_for_public_timeline(user) |> Map.put("only_media", "true") + + fetch_public_timeline(opts, "public timeline only media") + end + + defp fetch_public_timeline(opts, title) when is_binary(title) do + first_page_last = ActivityPub.fetch_public_activities(opts) |> List.last() + + second_page_last = + ActivityPub.fetch_public_activities(Map.put(opts, "max_id", first_page_last.id)) + |> List.last() + + third_page_last = + ActivityPub.fetch_public_activities(Map.put(opts, "max_id", second_page_last.id)) + |> List.last() + + forth_page_last = + ActivityPub.fetch_public_activities(Map.put(opts, "max_id", third_page_last.id)) + |> List.last() + + Benchee.run( + %{ + title => fn opts -> + ActivityPub.fetch_public_activities(opts) + end + }, + inputs: %{ + "1 page" => opts, + "2 page" => Map.put(opts, "max_id", first_page_last.id), + "3 page" => Map.put(opts, "max_id", second_page_last.id), + "4 page" => Map.put(opts, "max_id", third_page_last.id), + "5 page" => Map.put(opts, "max_id", forth_page_last.id) + }, + formatters: formatters() + ) + end + + defp opts_for_notifications do + %{"count" => "20", "with_muted" => "true"} + end + + defp fetch_notifications(user) do + opts = opts_for_notifications() + + first_page_last = MastodonAPI.get_notifications(user, opts) |> List.last() + + second_page_last = + MastodonAPI.get_notifications(user, Map.put(opts, "max_id", first_page_last.id)) + |> List.last() + + third_page_last = + MastodonAPI.get_notifications(user, Map.put(opts, "max_id", second_page_last.id)) + |> List.last() + + forth_page_last = + MastodonAPI.get_notifications(user, Map.put(opts, "max_id", third_page_last.id)) + |> List.last() + + Benchee.run( + %{ + "Notifications" => fn opts -> + MastodonAPI.get_notifications(user, opts) + end + }, + inputs: %{ + "1 page" => opts, + "2 page" => Map.put(opts, "max_id", first_page_last.id), + "3 page" => Map.put(opts, "max_id", second_page_last.id), + "4 page" => Map.put(opts, "max_id", third_page_last.id), + "5 page" => Map.put(opts, "max_id", forth_page_last.id) + }, + formatters: formatters() + ) + end + + defp fetch_favourites(user) do + first_page_last = ActivityPub.fetch_favourites(user) |> List.last() + + second_page_last = + ActivityPub.fetch_favourites(user, %{"max_id" => first_page_last.id}) |> List.last() + + third_page_last = + ActivityPub.fetch_favourites(user, %{"max_id" => second_page_last.id}) |> List.last() + + forth_page_last = + ActivityPub.fetch_favourites(user, %{"max_id" => third_page_last.id}) |> List.last() + + Benchee.run( + %{ + "Favourites" => fn opts -> + ActivityPub.fetch_favourites(user, opts) + end + }, + inputs: %{ + "1 page" => %{}, + "2 page" => %{"max_id" => first_page_last.id}, + "3 page" => %{"max_id" => second_page_last.id}, + "4 page" => %{"max_id" => third_page_last.id}, + "5 page" => %{"max_id" => forth_page_last.id} + }, + formatters: formatters() + ) + end + + defp opts_for_long_thread(user) do + %{ + "blocking_user" => user, "user" => user } + end - mastodon_public_timeline_params = %{ - "count" => 20, - "local_only" => true, - "only_media" => "false", - "type" => ["Create", "Announce"], - "with_muted" => "true", - "blocking_user" => user, - "muting_user" => user - } + defp fetch_long_thread(user) do + %{public_thread: public, private_thread: private} = + Agent.get(:benchmark_state, fn state -> state end) - mastodon_federated_timeline_params = %{ - "count" => 20, - "only_media" => "false", - "type" => ["Create", "Announce"], - "with_muted" => "true", - "blocking_user" => user, - "muting_user" => user - } + opts = opts_for_long_thread(user) - following = User.following(user) + private_input = {private.data["context"], Map.put(opts, "exclude_id", private.id)} - Benchee.run(%{ - "User home timeline" => fn -> - Pleroma.Web.ActivityPub.ActivityPub.fetch_activities( - following, - home_timeline_params - ) - end, - "User mastodon public timeline" => fn -> - Pleroma.Web.ActivityPub.ActivityPub.fetch_public_activities( - mastodon_public_timeline_params - ) - end, - "User mastodon federated public timeline" => fn -> - Pleroma.Web.ActivityPub.ActivityPub.fetch_public_activities( - mastodon_federated_timeline_params - ) - end - }) + public_input = {public.data["context"], Map.put(opts, "exclude_id", public.id)} - home_activities = - Pleroma.Web.ActivityPub.ActivityPub.fetch_activities( - following, - home_timeline_params + Benchee.run( + %{ + "fetch context" => fn {context, opts} -> + ActivityPub.fetch_activities_for_context(context, opts) + end + }, + inputs: %{ + "Private long thread" => private_input, + "Public long thread" => public_input + }, + formatters: formatters() + ) + end + + defp render_timelines(user) do + opts = opts_for_home_timeline(user) + + recipients = [user.ap_id | User.following(user)] + + home_activities = ActivityPub.fetch_activities(recipients, opts) |> Enum.reverse() + + recipients = [user.ap_id] + + opts = opts_for_direct_timeline(user) + + direct_activities = + recipients + |> ActivityPub.fetch_activities_query(opts) + |> Pagination.fetch_paginated(opts) + + opts = opts_for_public_timeline(user) + + public_activities = ActivityPub.fetch_public_activities(opts) + + opts = opts_for_public_timeline(user, :tag) + + tag_activities = ActivityPub.fetch_public_activities(opts) + + opts = opts_for_notifications() + + notifications = MastodonAPI.get_notifications(user, opts) + + favourites = ActivityPub.fetch_favourites(user) + + Benchee.run( + %{ + "Rendering home timeline" => fn -> + StatusView.render("index.json", %{ + activities: home_activities, + for: user, + as: :activity + }) + end, + "Rendering direct timeline" => fn -> + StatusView.render("index.json", %{ + activities: direct_activities, + for: user, + as: :activity + }) + end, + "Rendering public timeline" => fn -> + StatusView.render("index.json", %{ + activities: public_activities, + for: user, + as: :activity + }) + end, + "Rendering tag timeline" => fn -> + StatusView.render("index.json", %{ + activities: tag_activities, + for: user, + as: :activity + }) + end, + "Rendering notifications" => fn -> + Pleroma.Web.MastodonAPI.NotificationView.render("index.json", %{ + notifications: notifications, + for: user + }) + end, + "Rendering favourites timeline" => fn -> + StatusView.render("index.json", %{ + activities: favourites, + for: user, + as: :activity + }) + end + }, + formatters: formatters() + ) + end + + defp render_long_thread(user) do + %{public_thread: public, private_thread: private} = + Agent.get(:benchmark_state, fn state -> state end) + + opts = %{for: user} + public_activity = Activity.get_by_id_with_object(public.id) + private_activity = Activity.get_by_id_with_object(private.id) + + Benchee.run( + %{ + "render" => fn opts -> + StatusView.render("show.json", opts) + end + }, + inputs: %{ + "Public root" => Map.put(opts, :activity, public_activity), + "Private root" => Map.put(opts, :activity, private_activity) + }, + formatters: formatters() + ) + + fetch_opts = opts_for_long_thread(user) + + public_context = + ActivityPub.fetch_activities_for_context( + public.data["context"], + Map.put(fetch_opts, "exclude_id", public.id) ) - public_activities = - Pleroma.Web.ActivityPub.ActivityPub.fetch_public_activities(mastodon_public_timeline_params) - - public_federated_activities = - Pleroma.Web.ActivityPub.ActivityPub.fetch_public_activities( - mastodon_federated_timeline_params + private_context = + ActivityPub.fetch_activities_for_context( + private.data["context"], + Map.put(fetch_opts, "exclude_id", private.id) ) - Benchee.run(%{ - "Rendering home timeline" => fn -> - Pleroma.Web.MastodonAPI.StatusView.render("index.json", %{ - activities: home_activities, - for: user, - as: :activity - }) - end, - "Rendering public timeline" => fn -> - Pleroma.Web.MastodonAPI.StatusView.render("index.json", %{ - activities: public_activities, - for: user, - as: :activity - }) - end, - "Rendering public federated timeline" => fn -> - Pleroma.Web.MastodonAPI.StatusView.render("index.json", %{ - activities: public_federated_activities, - for: user, - as: :activity - }) - end, - "Rendering favorites timeline" => fn -> - conn = Phoenix.ConnTest.build_conn(:get, "http://localhost:4001/api/v1/favourites", nil) - Pleroma.Web.MastodonAPI.StatusController.favourites( - %Plug.Conn{conn | - assigns: %{user: user}, - query_params: %{"limit" => "0"}, - body_params: %{}, - cookies: %{}, - params: %{}, - path_params: %{}, - private: %{ - Pleroma.Web.Router => {[], %{}}, - phoenix_router: Pleroma.Web.Router, - phoenix_action: :favourites, - phoenix_controller: Pleroma.Web.MastodonAPI.StatusController, - phoenix_endpoint: Pleroma.Web.Endpoint, - phoenix_format: "json", - phoenix_layout: {Pleroma.Web.LayoutView, "app.html"}, - phoenix_recycled: true, - - phoenix_view: Pleroma.Web.MastodonAPI.StatusView, - plug_session: %{"user_id" => user.id}, - plug_session_fetch: :done, - plug_session_info: :write, - plug_skip_csrf_protection: true - } - }, - %{}) - end, - }) - end - - def query_notifications(user) do - without_muted_params = %{"count" => "20", "with_muted" => "false"} - with_muted_params = %{"count" => "20", "with_muted" => "true"} - - Benchee.run(%{ - "Notifications without muted" => fn -> - Pleroma.Web.MastodonAPI.MastodonAPI.get_notifications(user, without_muted_params) - end, - "Notifications with muted" => fn -> - Pleroma.Web.MastodonAPI.MastodonAPI.get_notifications(user, with_muted_params) - end - }) - - without_muted_notifications = - Pleroma.Web.MastodonAPI.MastodonAPI.get_notifications(user, without_muted_params) - - with_muted_notifications = - Pleroma.Web.MastodonAPI.MastodonAPI.get_notifications(user, with_muted_params) - - Benchee.run(%{ - "Render notifications without muted" => fn -> - Pleroma.Web.MastodonAPI.NotificationView.render("index.json", %{ - notifications: without_muted_notifications, - for: user - }) - end, - "Render notifications with muted" => fn -> - Pleroma.Web.MastodonAPI.NotificationView.render("index.json", %{ - notifications: with_muted_notifications, - for: user - }) - end - }) - end - - def query_dms(user) do - params = %{ - "count" => "20", - "with_muted" => "true", - "type" => "Create", - "blocking_user" => user, - "user" => user, - visibility: "direct" - } - - Benchee.run(%{ - "Direct messages with muted" => fn -> - Pleroma.Web.ActivityPub.ActivityPub.fetch_activities_query([user.ap_id], params) - |> Pleroma.Pagination.fetch_paginated(params) - end, - "Direct messages without muted" => fn -> - Pleroma.Web.ActivityPub.ActivityPub.fetch_activities_query([user.ap_id], params) - |> Pleroma.Pagination.fetch_paginated(Map.put(params, "with_muted", false)) - end - }) - - dms_with_muted = - Pleroma.Web.ActivityPub.ActivityPub.fetch_activities_query([user.ap_id], params) - |> Pleroma.Pagination.fetch_paginated(params) - - dms_without_muted = - Pleroma.Web.ActivityPub.ActivityPub.fetch_activities_query([user.ap_id], params) - |> Pleroma.Pagination.fetch_paginated(Map.put(params, "with_muted", false)) - - Benchee.run(%{ - "Rendering dms with muted" => fn -> - Pleroma.Web.MastodonAPI.StatusView.render("index.json", %{ - activities: dms_with_muted, - for: user, - as: :activity - }) - end, - "Rendering dms without muted" => fn -> - Pleroma.Web.MastodonAPI.StatusView.render("index.json", %{ - activities: dms_without_muted, - for: user, - as: :activity - }) - end - }) - end - - def query_long_thread(user, activity) do - Benchee.run(%{ - "Fetch main post" => fn -> - Pleroma.Activity.get_by_id_with_object(activity.id) - end, - "Fetch context of main post" => fn -> - Pleroma.Web.ActivityPub.ActivityPub.fetch_activities_for_context( - activity.data["context"], - %{ - "blocking_user" => user, - "user" => user, - "exclude_id" => activity.id - } - ) - end - }) - - activity = Pleroma.Activity.get_by_id_with_object(activity.id) - - context = - Pleroma.Web.ActivityPub.ActivityPub.fetch_activities_for_context( - activity.data["context"], - %{ - "blocking_user" => user, - "user" => user, - "exclude_id" => activity.id + Benchee.run( + %{ + "render" => fn opts -> + StatusView.render("context.json", opts) + end + }, + inputs: %{ + "Public context" => %{user: user, activity: public_activity, activities: public_context}, + "Private context" => %{ + user: user, + activity: private_activity, + activities: private_context } - ) - - Benchee.run(%{ - "Render status" => fn -> - Pleroma.Web.MastodonAPI.StatusView.render("show.json", %{ - activity: activity, - for: user - }) - end, - "Render context" => fn -> - Pleroma.Web.MastodonAPI.StatusView.render( - "index.json", - for: user, - activities: context, - as: :activity - ) - |> Enum.reverse() - end - }) + }, + formatters: formatters() + ) end end diff --git a/benchmarks/load_testing/generator.ex b/benchmarks/load_testing/generator.ex deleted file mode 100644 index e4673757c..000000000 --- a/benchmarks/load_testing/generator.ex +++ /dev/null @@ -1,410 +0,0 @@ -defmodule Pleroma.LoadTesting.Generator do - use Pleroma.LoadTesting.Helper - alias Pleroma.Web.CommonAPI - - def generate_like_activities(user, posts) do - count_likes = Kernel.trunc(length(posts) / 4) - IO.puts("Starting generating #{count_likes} like activities...") - - {time, _} = - :timer.tc(fn -> - Task.async_stream( - Enum.take_random(posts, count_likes), - fn post -> {:ok, _, _} = CommonAPI.favorite(post.id, user) end, - max_concurrency: 10, - timeout: 30_000 - ) - |> Stream.run() - end) - - IO.puts("Inserting like activities take #{to_sec(time)} sec.\n") - end - - def generate_users(opts) do - IO.puts("Starting generating #{opts[:users_max]} users...") - {time, users} = :timer.tc(fn -> do_generate_users(opts) end) - - IO.puts("Inserting users took #{to_sec(time)} sec.\n") - users - end - - defp do_generate_users(opts) do - max = Keyword.get(opts, :users_max) - - Task.async_stream( - 1..max, - &generate_user_data(&1), - max_concurrency: 10, - timeout: 30_000 - ) - |> Enum.to_list() - end - - defp generate_user_data(i) do - remote = Enum.random([true, false]) - - user = %User{ - name: "Test テスト User #{i}", - email: "user#{i}@example.com", - nickname: "nick#{i}", - password_hash: - "$pbkdf2-sha512$160000$bU.OSFI7H/yqWb5DPEqyjw$uKp/2rmXw12QqnRRTqTtuk2DTwZfF8VR4MYW2xMeIlqPR/UX1nT1CEKVUx2CowFMZ5JON8aDvURrZpJjSgqXrg", - bio: "Tester Number #{i}", - local: remote - } - - user_urls = - if remote do - base_url = - Enum.random(["https://domain1.com", "https://domain2.com", "https://domain3.com"]) - - ap_id = "#{base_url}/users/#{user.nickname}" - - %{ - ap_id: ap_id, - follower_address: ap_id <> "/followers", - following_address: ap_id <> "/following" - } - else - %{ - ap_id: User.ap_id(user), - follower_address: User.ap_followers(user), - following_address: User.ap_following(user) - } - end - - user = Map.merge(user, user_urls) - - Repo.insert!(user) - end - - def generate_activities(user, users) do - do_generate_activities(user, users) - end - - defp do_generate_activities(user, users) do - IO.puts("Starting generating 20000 common activities...") - - {time, _} = - :timer.tc(fn -> - Task.async_stream( - 1..20_000, - fn _ -> - do_generate_activity([user | users]) - end, - max_concurrency: 10, - timeout: 30_000 - ) - |> Stream.run() - end) - - IO.puts("Inserting common activities take #{to_sec(time)} sec.\n") - - IO.puts("Starting generating 20000 activities with mentions...") - - {time, _} = - :timer.tc(fn -> - Task.async_stream( - 1..20_000, - fn _ -> - do_generate_activity_with_mention(user, users) - end, - max_concurrency: 10, - timeout: 30_000 - ) - |> Stream.run() - end) - - IO.puts("Inserting activities with menthions take #{to_sec(time)} sec.\n") - - IO.puts("Starting generating 10000 activities with threads...") - - {time, _} = - :timer.tc(fn -> - Task.async_stream( - 1..10_000, - fn _ -> - do_generate_threads([user | users]) - end, - max_concurrency: 10, - timeout: 30_000 - ) - |> Stream.run() - end) - - IO.puts("Inserting activities with threads take #{to_sec(time)} sec.\n") - end - - defp do_generate_activity(users) do - post = %{ - "status" => "Some status without mention with random user" - } - - CommonAPI.post(Enum.random(users), post) - end - - def generate_power_intervals(opts \\ []) do - count = Keyword.get(opts, :count, 20) - power = Keyword.get(opts, :power, 2) - IO.puts("Generating #{count} intervals for a power #{power} series...") - counts = Enum.map(1..count, fn n -> :math.pow(n, power) end) - sum = Enum.sum(counts) - - densities = - Enum.map(counts, fn c -> - c / sum - end) - - densities - |> Enum.reduce(0, fn density, acc -> - if acc == 0 do - [{0, density}] - else - [{_, lower} | _] = acc - [{lower, lower + density} | acc] - end - end) - |> Enum.reverse() - end - - def generate_tagged_activities(opts \\ []) do - tag_count = Keyword.get(opts, :tag_count, 20) - users = Keyword.get(opts, :users, Repo.all(User)) - activity_count = Keyword.get(opts, :count, 200_000) - - intervals = generate_power_intervals(count: tag_count) - - IO.puts( - "Generating #{activity_count} activities using #{tag_count} different tags of format `tag_n`, starting at tag_0" - ) - - Enum.each(1..activity_count, fn _ -> - random = :rand.uniform() - i = Enum.find_index(intervals, fn {lower, upper} -> lower <= random && upper > random end) - CommonAPI.post(Enum.random(users), %{"status" => "a post with the tag #tag_#{i}"}) - end) - end - - defp do_generate_activity_with_mention(user, users) do - mentions_cnt = Enum.random([2, 3, 4, 5]) - with_user = Enum.random([true, false]) - users = Enum.shuffle(users) - mentions_users = Enum.take(users, mentions_cnt) - mentions_users = if with_user, do: [user | mentions_users], else: mentions_users - - mentions_str = - Enum.map(mentions_users, fn user -> "@" <> user.nickname end) |> Enum.join(", ") - - post = %{ - "status" => mentions_str <> "some status with mentions random users" - } - - CommonAPI.post(Enum.random(users), post) - end - - defp do_generate_threads(users) do - thread_length = Enum.random([2, 3, 4, 5]) - actor = Enum.random(users) - - post = %{ - "status" => "Start of the thread" - } - - {:ok, activity} = CommonAPI.post(actor, post) - - Enum.each(1..thread_length, fn _ -> - user = Enum.random(users) - - post = %{ - "status" => "@#{actor.nickname} reply to thread", - "in_reply_to_status_id" => activity.id - } - - CommonAPI.post(user, post) - end) - end - - def generate_remote_activities(user, users) do - do_generate_remote_activities(user, users) - end - - defp do_generate_remote_activities(user, users) do - IO.puts("Starting generating 10000 remote activities...") - - {time, _} = - :timer.tc(fn -> - Task.async_stream( - 1..10_000, - fn i -> - do_generate_remote_activity(i, user, users) - end, - max_concurrency: 10, - timeout: 30_000 - ) - |> Stream.run() - end) - - IO.puts("Inserting remote activities take #{to_sec(time)} sec.\n") - end - - defp do_generate_remote_activity(i, user, users) do - actor = Enum.random(users) - %{host: host} = URI.parse(actor.ap_id) - date = Date.utc_today() - datetime = DateTime.utc_now() - - map = %{ - "actor" => actor.ap_id, - "cc" => [actor.follower_address, user.ap_id], - "context" => "tag:mastodon.example.org,#{date}:objectId=#{i}:objectType=Conversation", - "id" => actor.ap_id <> "/statuses/#{i}/activity", - "object" => %{ - "actor" => actor.ap_id, - "atomUri" => actor.ap_id <> "/statuses/#{i}", - "attachment" => [], - "attributedTo" => actor.ap_id, - "bcc" => [], - "bto" => [], - "cc" => [actor.follower_address, user.ap_id], - "content" => - "

- user.ap_id <> - "\" class=\"u-url mention\">@" <> user.nickname <> "

", - "context" => "tag:mastodon.example.org,#{date}:objectId=#{i}:objectType=Conversation", - "conversation" => - "tag:mastodon.example.org,#{date}:objectId=#{i}:objectType=Conversation", - "emoji" => %{}, - "id" => actor.ap_id <> "/statuses/#{i}", - "inReplyTo" => nil, - "inReplyToAtomUri" => nil, - "published" => datetime, - "sensitive" => true, - "summary" => "cw", - "tag" => [ - %{ - "href" => user.ap_id, - "name" => "@#{user.nickname}@#{host}", - "type" => "Mention" - } - ], - "to" => ["https://www.w3.org/ns/activitystreams#Public"], - "type" => "Note", - "url" => "http://#{host}/@#{actor.nickname}/#{i}" - }, - "published" => datetime, - "to" => ["https://www.w3.org/ns/activitystreams#Public"], - "type" => "Create" - } - - Pleroma.Web.ActivityPub.ActivityPub.insert(map, false) - end - - def generate_dms(user, users, opts) do - IO.puts("Starting generating #{opts[:dms_max]} DMs") - {time, _} = :timer.tc(fn -> do_generate_dms(user, users, opts) end) - IO.puts("Inserting dms take #{to_sec(time)} sec.\n") - end - - defp do_generate_dms(user, users, opts) do - Task.async_stream( - 1..opts[:dms_max], - fn _ -> - do_generate_dm(user, users) - end, - max_concurrency: 10, - timeout: 30_000 - ) - |> Stream.run() - end - - defp do_generate_dm(user, users) do - post = %{ - "status" => "@#{user.nickname} some direct message", - "visibility" => "direct" - } - - CommonAPI.post(Enum.random(users), post) - end - - def generate_long_thread(user, users, opts) do - IO.puts("Starting generating long thread with #{opts[:thread_length]} replies") - {time, activity} = :timer.tc(fn -> do_generate_long_thread(user, users, opts) end) - IO.puts("Inserting long thread replies take #{to_sec(time)} sec.\n") - {:ok, activity} - end - - defp do_generate_long_thread(user, users, opts) do - {:ok, %{id: id} = activity} = CommonAPI.post(user, %{"status" => "Start of long thread"}) - - Task.async_stream( - 1..opts[:thread_length], - fn _ -> do_generate_thread(users, id) end, - max_concurrency: 10, - timeout: 30_000 - ) - |> Stream.run() - - activity - end - - defp do_generate_thread(users, activity_id) do - CommonAPI.post(Enum.random(users), %{ - "status" => "reply to main post", - "in_reply_to_status_id" => activity_id - }) - end - - def generate_non_visible_message(user, users) do - IO.puts("Starting generating 1000 non visible posts") - - {time, _} = - :timer.tc(fn -> - do_generate_non_visible_posts(user, users) - end) - - IO.puts("Inserting non visible posts take #{to_sec(time)} sec.\n") - end - - defp do_generate_non_visible_posts(user, users) do - [not_friend | users] = users - - make_friends(user, users) - - Task.async_stream(1..1000, fn _ -> do_generate_non_visible_post(not_friend, users) end, - max_concurrency: 10, - timeout: 30_000 - ) - |> Stream.run() - end - - defp make_friends(_user, []), do: nil - - defp make_friends(user, [friend | users]) do - {:ok, _} = User.follow(user, friend) - {:ok, _} = User.follow(friend, user) - make_friends(user, users) - end - - defp do_generate_non_visible_post(not_friend, users) do - post = %{ - "status" => "some non visible post", - "visibility" => "private" - } - - {:ok, activity} = CommonAPI.post(not_friend, post) - - thread_length = Enum.random([2, 3, 4, 5]) - - Enum.each(1..thread_length, fn _ -> - user = Enum.random(users) - - post = %{ - "status" => "@#{not_friend.nickname} reply to non visible post", - "in_reply_to_status_id" => activity.id, - "visibility" => "private" - } - - CommonAPI.post(user, post) - end) - end -end diff --git a/benchmarks/load_testing/helper.ex b/benchmarks/load_testing/helper.ex index 47b25c65f..23bbb1cec 100644 --- a/benchmarks/load_testing/helper.ex +++ b/benchmarks/load_testing/helper.ex @@ -1,11 +1,3 @@ defmodule Pleroma.LoadTesting.Helper do - defmacro __using__(_) do - quote do - import Ecto.Query - alias Pleroma.Repo - alias Pleroma.User - - defp to_sec(microseconds), do: microseconds / 1_000_000 - end - end + def to_sec(microseconds), do: microseconds / 1_000_000 end diff --git a/benchmarks/load_testing/users.ex b/benchmarks/load_testing/users.ex new file mode 100644 index 000000000..951b30d91 --- /dev/null +++ b/benchmarks/load_testing/users.ex @@ -0,0 +1,161 @@ +defmodule Pleroma.LoadTesting.Users do + @moduledoc """ + Module for generating users with friends. + """ + import Ecto.Query + import Pleroma.LoadTesting.Helper, only: [to_sec: 1] + + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.User.Query + + @defaults [ + users: 20_000, + friends: 100 + ] + + @max_concurrency 30 + + @spec generate(keyword()) :: User.t() + def generate(opts \\ []) do + opts = Keyword.merge(@defaults, opts) + + IO.puts("Starting generating #{opts[:users]} users...") + + {time, _} = :timer.tc(fn -> generate_users(opts[:users]) end) + + IO.puts("Generating users take #{to_sec(time)} sec.\n") + + main_user = + Repo.one(from(u in User, where: u.local == true, order_by: fragment("RANDOM()"), limit: 1)) + + IO.puts("Starting making friends for #{opts[:friends]} users...") + {time, _} = :timer.tc(fn -> make_friends(main_user, opts[:friends]) end) + + IO.puts("Making friends take #{to_sec(time)} sec.\n") + + Repo.get(User, main_user.id) + end + + defp generate_users(max) do + Task.async_stream( + 1..max, + &generate_user(&1), + max_concurrency: @max_concurrency, + timeout: 30_000 + ) + |> Stream.run() + end + + defp generate_user(i) do + remote = Enum.random([true, false]) + + %User{ + name: "Test テスト User #{i}", + email: "user#{i}@example.com", + nickname: "nick#{i}", + password_hash: Comeonin.Pbkdf2.hashpwsalt("test"), + bio: "Tester Number #{i}", + local: !remote + } + |> user_urls() + |> Repo.insert!() + end + + defp user_urls(%{local: true} = user) do + urls = %{ + ap_id: User.ap_id(user), + follower_address: User.ap_followers(user), + following_address: User.ap_following(user) + } + + Map.merge(user, urls) + end + + defp user_urls(%{local: false} = user) do + base_domain = Enum.random(["domain1.com", "domain2.com", "domain3.com"]) + + ap_id = "https://#{base_domain}/users/#{user.nickname}" + + urls = %{ + ap_id: ap_id, + follower_address: ap_id <> "/followers", + following_address: ap_id <> "/following" + } + + Map.merge(user, urls) + end + + defp make_friends(main_user, max) when is_integer(max) do + number_of_users = + (max / 2) + |> Kernel.trunc() + + main_user + |> get_users(%{limit: number_of_users, local: :local}) + |> run_stream(main_user) + + main_user + |> get_users(%{limit: number_of_users, local: :external}) + |> run_stream(main_user) + end + + defp make_friends(%User{} = main_user, %User{} = user) do + {:ok, _} = User.follow(main_user, user) + {:ok, _} = User.follow(user, main_user) + end + + @spec get_users(User.t(), keyword()) :: [User.t()] + def get_users(user, opts) do + criteria = %{limit: opts[:limit]} + + criteria = + if opts[:local] do + Map.put(criteria, opts[:local], true) + else + criteria + end + + criteria = + if opts[:friends?] do + Map.put(criteria, :friends, user) + else + criteria + end + + query = + criteria + |> Query.build() + |> random_without_user(user) + + query = + if opts[:friends?] == false do + friends_ids = + %{friends: user} + |> Query.build() + |> Repo.all() + |> Enum.map(& &1.id) + + from(u in query, where: u.id not in ^friends_ids) + else + query + end + + Repo.all(query) + end + + defp random_without_user(query, user) do + from(u in query, + where: u.id != ^user.id, + order_by: fragment("RANDOM()") + ) + end + + defp run_stream(users, main_user) do + Task.async_stream(users, &make_friends(main_user, &1), + max_concurrency: @max_concurrency, + timeout: 30_000 + ) + |> Stream.run() + end +end diff --git a/benchmarks/mix/tasks/pleroma/load_testing.ex b/benchmarks/mix/tasks/pleroma/load_testing.ex index 0a751adac..262300990 100644 --- a/benchmarks/mix/tasks/pleroma/load_testing.ex +++ b/benchmarks/mix/tasks/pleroma/load_testing.ex @@ -1,114 +1,55 @@ defmodule Mix.Tasks.Pleroma.LoadTesting do use Mix.Task - use Pleroma.LoadTesting.Helper - import Mix.Pleroma - import Pleroma.LoadTesting.Generator - import Pleroma.LoadTesting.Fetcher + import Ecto.Query + + alias Ecto.Adapters.SQL + alias Pleroma.Repo + alias Pleroma.User @shortdoc "Factory for generation data" @moduledoc """ Generates data like: - local/remote users - - local/remote activities with notifications - - direct messages - - long thread - - non visible posts + - local/remote activities with differrent visibility: + - simple activiities + - with emoji + - with mentions + - hellthreads + - with attachments + - with tags + - likes + - reblogs + - simple threads + - long threads ## Generate data - MIX_ENV=benchmark mix pleroma.load_testing --users 20000 --dms 20000 --thread_length 2000 - MIX_ENV=benchmark mix pleroma.load_testing -u 20000 -d 20000 -t 2000 + MIX_ENV=benchmark mix pleroma.load_testing --users 20000 --friends 1000 --iterations 170 --friends_used 20 --non_friends_used 20 + MIX_ENV=benchmark mix pleroma.load_testing -u 20000 -f 1000 -i 170 -fu 20 -nfu 20 Options: - `--users NUMBER` - number of users to generate. Defaults to: 20000. Alias: `-u` - - `--dms NUMBER` - number of direct messages to generate. Defaults to: 20000. Alias `-d` - - `--thread_length` - number of messages in thread. Defaults to: 2000. ALias `-t` + - `--friends NUMBER` - number of friends for main user. Defaults to: 1000. Alias: `-f` + - `--iterations NUMBER` - number of iterations to generate activities. For each iteration in database is inserted about 120+ activities with different visibility, actors and types.Defaults to: 170. Alias: `-i` + - `--friends_used NUMBER` - number of main user friends used in activity generation. Defaults to: 20. Alias: `-fu` + - `--non_friends_used NUMBER` - number of non friends used in activity generation. Defaults to: 20. Alias: `-nfu` """ - @aliases [u: :users, d: :dms, t: :thread_length] + @aliases [u: :users, f: :friends, i: :iterations, fu: :friends_used, nfu: :non_friends_used] @switches [ users: :integer, - dms: :integer, - thread_length: :integer + friends: :integer, + iterations: :integer, + friends_used: :integer, + non_friends_used: :integer ] - @users_default 20_000 - @dms_default 1_000 - @thread_length_default 2_000 def run(args) do - start_pleroma() - Pleroma.Config.put([:instance, :skip_thread_containment], true) + Mix.Pleroma.start_pleroma() + clean_tables() {opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) - users_max = Keyword.get(opts, :users, @users_default) - dms_max = Keyword.get(opts, :dms, @dms_default) - thread_length = Keyword.get(opts, :thread_length, @thread_length_default) - - clean_tables() - - opts = - Keyword.put(opts, :users_max, users_max) - |> Keyword.put(:dms_max, dms_max) - |> Keyword.put(:thread_length, thread_length) - - generate_users(opts) - - # main user for queries - IO.puts("Fetching local main user...") - - {time, user} = - :timer.tc(fn -> - Repo.one( - from(u in User, where: u.local == true, order_by: fragment("RANDOM()"), limit: 1) - ) - end) - - IO.puts("Fetching main user take #{to_sec(time)} sec.\n") - - IO.puts("Fetching local users...") - - {time, users} = - :timer.tc(fn -> - Repo.all( - from(u in User, - where: u.id != ^user.id, - where: u.local == true, - order_by: fragment("RANDOM()"), - limit: 10 - ) - ) - end) - - IO.puts("Fetching local users take #{to_sec(time)} sec.\n") - - IO.puts("Fetching remote users...") - - {time, remote_users} = - :timer.tc(fn -> - Repo.all( - from(u in User, - where: u.id != ^user.id, - where: u.local == false, - order_by: fragment("RANDOM()"), - limit: 10 - ) - ) - end) - - IO.puts("Fetching remote users take #{to_sec(time)} sec.\n") - - generate_activities(user, users) - - generate_remote_activities(user, remote_users) - - generate_like_activities( - user, Pleroma.Repo.all(Pleroma.Activity.Queries.by_type("Create")) - ) - - generate_dms(user, users, opts) - - {:ok, activity} = generate_long_thread(user, users, opts) - - generate_non_visible_message(user, users) + user = Pleroma.LoadTesting.Users.generate(opts) + Pleroma.LoadTesting.Activities.generate(user, opts) IO.puts("Users in DB: #{Repo.aggregate(from(u in User), :count, :id)}") @@ -120,19 +61,14 @@ def run(args) do "Notifications in DB: #{Repo.aggregate(from(n in Pleroma.Notification), :count, :id)}" ) - fetch_user(user) - query_timelines(user) - query_notifications(user) - query_dms(user) - query_long_thread(user, activity) - Pleroma.Config.put([:instance, :skip_thread_containment], false) - query_timelines(user) + Pleroma.LoadTesting.Fetcher.run_benchmarks(user) end defp clean_tables do IO.puts("Deleting old data...\n") - Ecto.Adapters.SQL.query!(Repo, "TRUNCATE users CASCADE;") - Ecto.Adapters.SQL.query!(Repo, "TRUNCATE activities CASCADE;") - Ecto.Adapters.SQL.query!(Repo, "TRUNCATE objects CASCADE;") + SQL.query!(Repo, "TRUNCATE users CASCADE;") + SQL.query!(Repo, "TRUNCATE activities CASCADE;") + SQL.query!(Repo, "TRUNCATE objects CASCADE;") + SQL.query!(Repo, "TRUNCATE oban_jobs CASCADE;") end end diff --git a/config/benchmark.exs b/config/benchmark.exs index ff59395cf..e867253eb 100644 --- a/config/benchmark.exs +++ b/config/benchmark.exs @@ -39,7 +39,7 @@ adapter: Ecto.Adapters.Postgres, username: "postgres", password: "postgres", - database: "pleroma_test", + database: "pleroma_benchmark", hostname: System.get_env("DB_HOST") || "localhost", pool_size: 10 diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 33f1705df..51850abb5 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -157,7 +157,7 @@ defp build_cachex(type, opts), defp chat_enabled?, do: Pleroma.Config.get([:chat, :enabled]) - defp streamer_child(:test), do: [] + defp streamer_child(env) when env in [:test, :benchmark], do: [] defp streamer_child(_) do [Pleroma.Web.Streamer.supervisor()] From 1f29ecdcd7ecdc4ad8d6bc8fc4c34efbc9b7fe1d Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 18 Feb 2020 12:19:10 +0300 Subject: [PATCH 127/188] sync with develop --- benchmarks/load_testing/activities.ex | 42 +++++++++++++ benchmarks/load_testing/helper.ex | 11 ++++ benchmarks/load_testing/users.ex | 61 +++++++++++-------- .../mix/tasks/pleroma/benchmarks/tags.ex | 24 +++----- benchmarks/mix/tasks/pleroma/load_testing.ex | 10 +-- lib/mix/pleroma.ex | 1 + 6 files changed, 99 insertions(+), 50 deletions(-) diff --git a/benchmarks/load_testing/activities.ex b/benchmarks/load_testing/activities.ex index db0e5a66f..121d5c500 100644 --- a/benchmarks/load_testing/activities.ex +++ b/benchmarks/load_testing/activities.ex @@ -85,6 +85,48 @@ def generate(user, opts \\ []) do :ok end + def generate_power_intervals(opts \\ []) do + count = Keyword.get(opts, :count, 20) + power = Keyword.get(opts, :power, 2) + IO.puts("Generating #{count} intervals for a power #{power} series...") + counts = Enum.map(1..count, fn n -> :math.pow(n, power) end) + sum = Enum.sum(counts) + + densities = + Enum.map(counts, fn c -> + c / sum + end) + + densities + |> Enum.reduce(0, fn density, acc -> + if acc == 0 do + [{0, density}] + else + [{_, lower} | _] = acc + [{lower, lower + density} | acc] + end + end) + |> Enum.reverse() + end + + def generate_tagged_activities(opts \\ []) do + tag_count = Keyword.get(opts, :tag_count, 20) + users = Keyword.get(opts, :users, Repo.all(Pleroma.User)) + activity_count = Keyword.get(opts, :count, 200_000) + + intervals = generate_power_intervals(count: tag_count) + + IO.puts( + "Generating #{activity_count} activities using #{tag_count} different tags of format `tag_n`, starting at tag_0" + ) + + Enum.each(1..activity_count, fn _ -> + random = :rand.uniform() + i = Enum.find_index(intervals, fn {lower, upper} -> lower <= random && upper > random end) + CommonAPI.post(Enum.random(users), %{"status" => "a post with the tag #tag_#{i}"}) + end) + end + defp generate_long_thread(visibility, user, friends, non_friends, _opts) do group = if visibility == "public", diff --git a/benchmarks/load_testing/helper.ex b/benchmarks/load_testing/helper.ex index 23bbb1cec..cab60acb4 100644 --- a/benchmarks/load_testing/helper.ex +++ b/benchmarks/load_testing/helper.ex @@ -1,3 +1,14 @@ defmodule Pleroma.LoadTesting.Helper do + alias Ecto.Adapters.SQL + alias Pleroma.Repo + def to_sec(microseconds), do: microseconds / 1_000_000 + + def clean_tables do + IO.puts("Deleting old data...\n") + SQL.query!(Repo, "TRUNCATE users CASCADE;") + SQL.query!(Repo, "TRUNCATE activities CASCADE;") + SQL.query!(Repo, "TRUNCATE objects CASCADE;") + SQL.query!(Repo, "TRUNCATE oban_jobs CASCADE;") + end end diff --git a/benchmarks/load_testing/users.ex b/benchmarks/load_testing/users.ex index 951b30d91..bc31dc08b 100644 --- a/benchmarks/load_testing/users.ex +++ b/benchmarks/load_testing/users.ex @@ -20,31 +20,31 @@ defmodule Pleroma.LoadTesting.Users do def generate(opts \\ []) do opts = Keyword.merge(@defaults, opts) - IO.puts("Starting generating #{opts[:users]} users...") - - {time, _} = :timer.tc(fn -> generate_users(opts[:users]) end) - - IO.puts("Generating users take #{to_sec(time)} sec.\n") + generate_users(opts[:users]) main_user = Repo.one(from(u in User, where: u.local == true, order_by: fragment("RANDOM()"), limit: 1)) - IO.puts("Starting making friends for #{opts[:friends]} users...") - {time, _} = :timer.tc(fn -> make_friends(main_user, opts[:friends]) end) - - IO.puts("Making friends take #{to_sec(time)} sec.\n") + make_friends(main_user, opts[:friends]) Repo.get(User, main_user.id) end - defp generate_users(max) do - Task.async_stream( - 1..max, - &generate_user(&1), - max_concurrency: @max_concurrency, - timeout: 30_000 - ) - |> Stream.run() + def generate_users(max) do + IO.puts("Starting generating #{opts[:users]} users...") + + {time, _} = + :timer.tc(fn -> + Task.async_stream( + 1..max, + &generate_user(&1), + max_concurrency: @max_concurrency, + timeout: 30_000 + ) + |> Stream.run() + end) + + IO.puts("Generating users take #{to_sec(time)} sec.\n") end defp generate_user(i) do @@ -86,18 +86,25 @@ defp user_urls(%{local: false} = user) do Map.merge(user, urls) end - defp make_friends(main_user, max) when is_integer(max) do - number_of_users = - (max / 2) - |> Kernel.trunc() + def make_friends(main_user, max) when is_integer(max) do + IO.puts("Starting making friends for #{opts[:friends]} users...") - main_user - |> get_users(%{limit: number_of_users, local: :local}) - |> run_stream(main_user) + {time, _} = + :timer.tc(fn -> + number_of_users = + (max / 2) + |> Kernel.trunc() - main_user - |> get_users(%{limit: number_of_users, local: :external}) - |> run_stream(main_user) + main_user + |> get_users(%{limit: number_of_users, local: :local}) + |> run_stream(main_user) + + main_user + |> get_users(%{limit: number_of_users, local: :external}) + |> run_stream(main_user) + end) + + IO.puts("Making friends take #{to_sec(time)} sec.\n") end defp make_friends(%User{} = main_user, %User{} = user) do diff --git a/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex b/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex index fd1506907..657403202 100644 --- a/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex +++ b/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex @@ -1,9 +1,12 @@ defmodule Mix.Tasks.Pleroma.Benchmarks.Tags do use Mix.Task - alias Pleroma.Repo - alias Pleroma.LoadTesting.Generator + + import Pleroma.LoadTesting.Helper, only: [clean_tables: 0] import Ecto.Query + alias Pleroma.Repo + alias Pleroma.Web.MastodonAPI.TimelineController + def run(_args) do Mix.Pleroma.start_pleroma() activities_count = Repo.aggregate(from(a in Pleroma.Activity), :count, :id) @@ -11,8 +14,8 @@ def run(_args) do if activities_count == 0 do IO.puts("Did not find any activities, cleaning and generating") clean_tables() - Generator.generate_users(users_max: 10) - Generator.generate_tagged_activities() + Pleroma.LoadTesting.Users.generate_users(10) + Pleroma.LoadTesting.Activities.generate_tagged_activities() else IO.puts("Found #{activities_count} activities, won't generate new ones") end @@ -34,7 +37,7 @@ def run(_args) do Benchee.run( %{ "Hashtag fetching, any" => fn tags -> - Pleroma.Web.MastodonAPI.TimelineController.hashtag_fetching( + TimelineController.hashtag_fetching( %{ "any" => tags }, @@ -44,7 +47,7 @@ def run(_args) do end, # Will always return zero results because no overlapping hashtags are generated. "Hashtag fetching, all" => fn tags -> - Pleroma.Web.MastodonAPI.TimelineController.hashtag_fetching( + TimelineController.hashtag_fetching( %{ "all" => tags }, @@ -64,7 +67,7 @@ def run(_args) do Benchee.run( %{ "Hashtag fetching" => fn tag -> - Pleroma.Web.MastodonAPI.TimelineController.hashtag_fetching( + TimelineController.hashtag_fetching( %{ "tag" => tag }, @@ -77,11 +80,4 @@ def run(_args) do time: 5 ) end - - defp clean_tables do - IO.puts("Deleting old data...\n") - Ecto.Adapters.SQL.query!(Repo, "TRUNCATE users CASCADE;") - Ecto.Adapters.SQL.query!(Repo, "TRUNCATE activities CASCADE;") - Ecto.Adapters.SQL.query!(Repo, "TRUNCATE objects CASCADE;") - end end diff --git a/benchmarks/mix/tasks/pleroma/load_testing.ex b/benchmarks/mix/tasks/pleroma/load_testing.ex index 262300990..72b225f09 100644 --- a/benchmarks/mix/tasks/pleroma/load_testing.ex +++ b/benchmarks/mix/tasks/pleroma/load_testing.ex @@ -1,8 +1,8 @@ defmodule Mix.Tasks.Pleroma.LoadTesting do use Mix.Task import Ecto.Query + import Pleroma.LoadTesting.Helper, only: [clean_tables: 0] - alias Ecto.Adapters.SQL alias Pleroma.Repo alias Pleroma.User @@ -63,12 +63,4 @@ def run(args) do Pleroma.LoadTesting.Fetcher.run_benchmarks(user) end - - defp clean_tables do - IO.puts("Deleting old data...\n") - SQL.query!(Repo, "TRUNCATE users CASCADE;") - SQL.query!(Repo, "TRUNCATE activities CASCADE;") - SQL.query!(Repo, "TRUNCATE objects CASCADE;") - SQL.query!(Repo, "TRUNCATE oban_jobs CASCADE;") - end end diff --git a/lib/mix/pleroma.ex b/lib/mix/pleroma.ex index 3ad6edbfb..4dfcc32e7 100644 --- a/lib/mix/pleroma.ex +++ b/lib/mix/pleroma.ex @@ -5,6 +5,7 @@ defmodule Mix.Pleroma do @doc "Common functions to be reused in mix tasks" def start_pleroma do + Mix.Task.run("app.start") Application.put_env(:phoenix, :serve_endpoints, false, persistent: true) if Pleroma.Config.get(:env) != :test do From 56503c385e8412a1189748bcf3fdfd4090be9f56 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 17 Mar 2020 13:47:13 +0300 Subject: [PATCH 128/188] fix --- benchmarks/load_testing/activities.ex | 4 ++-- benchmarks/load_testing/users.ex | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/benchmarks/load_testing/activities.ex b/benchmarks/load_testing/activities.ex index 121d5c500..24c6b5531 100644 --- a/benchmarks/load_testing/activities.ex +++ b/benchmarks/load_testing/activities.ex @@ -19,7 +19,7 @@ defmodule Pleroma.LoadTesting.Activities do non_friends_used: 20 ] - @max_concurrency 30 + @max_concurrency 10 @visibility ~w(public private direct unlisted) @types ~w(simple emoji mentions hell_thread attachment tag like reblog simple_thread remote) @@ -81,7 +81,7 @@ def generate(user, opts \\ []) do ) end) - IO.puts("Generating iterations activities take #{to_sec(time)} sec.\n") + IO.puts("Generating iterations of activities take #{to_sec(time)} sec.\n") :ok end diff --git a/benchmarks/load_testing/users.ex b/benchmarks/load_testing/users.ex index bc31dc08b..b73ac8651 100644 --- a/benchmarks/load_testing/users.ex +++ b/benchmarks/load_testing/users.ex @@ -14,7 +14,7 @@ defmodule Pleroma.LoadTesting.Users do friends: 100 ] - @max_concurrency 30 + @max_concurrency 10 @spec generate(keyword()) :: User.t() def generate(opts \\ []) do @@ -31,7 +31,7 @@ def generate(opts \\ []) do end def generate_users(max) do - IO.puts("Starting generating #{opts[:users]} users...") + IO.puts("Starting generating #{max} users...") {time, _} = :timer.tc(fn -> @@ -87,7 +87,7 @@ defp user_urls(%{local: false} = user) do end def make_friends(main_user, max) when is_integer(max) do - IO.puts("Starting making friends for #{opts[:friends]} users...") + IO.puts("Starting making friends for #{max} users...") {time, _} = :timer.tc(fn -> @@ -107,7 +107,7 @@ def make_friends(main_user, max) when is_integer(max) do IO.puts("Making friends take #{to_sec(time)} sec.\n") end - defp make_friends(%User{} = main_user, %User{} = user) do + def make_friends(%User{} = main_user, %User{} = user) do {:ok, _} = User.follow(main_user, user) {:ok, _} = User.follow(user, main_user) end From 96e279655763fedcb701e59c500023a70568c4c6 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 30 Mar 2020 11:59:14 +0300 Subject: [PATCH 129/188] use in timelines benchmark new user generator --- benchmarks/load_testing/activities.ex | 2 +- benchmarks/load_testing/users.ex | 9 ++++---- .../mix/tasks/pleroma/benchmarks/timelines.ex | 22 +++++++------------ 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/benchmarks/load_testing/activities.ex b/benchmarks/load_testing/activities.ex index 24c6b5531..23ee2b987 100644 --- a/benchmarks/load_testing/activities.ex +++ b/benchmarks/load_testing/activities.ex @@ -81,7 +81,7 @@ def generate(user, opts \\ []) do ) end) - IO.puts("Generating iterations of activities take #{to_sec(time)} sec.\n") + IO.puts("Generating iterations of activities took #{to_sec(time)} sec.\n") :ok end diff --git a/benchmarks/load_testing/users.ex b/benchmarks/load_testing/users.ex index b73ac8651..1a8c6e22f 100644 --- a/benchmarks/load_testing/users.ex +++ b/benchmarks/load_testing/users.ex @@ -33,7 +33,7 @@ def generate(opts \\ []) do def generate_users(max) do IO.puts("Starting generating #{max} users...") - {time, _} = + {time, users} = :timer.tc(fn -> Task.async_stream( 1..max, @@ -41,10 +41,11 @@ def generate_users(max) do max_concurrency: @max_concurrency, timeout: 30_000 ) - |> Stream.run() + |> Enum.to_list() end) - IO.puts("Generating users take #{to_sec(time)} sec.\n") + IO.puts("Generating users took #{to_sec(time)} sec.\n") + users end defp generate_user(i) do @@ -104,7 +105,7 @@ def make_friends(main_user, max) when is_integer(max) do |> run_stream(main_user) end) - IO.puts("Making friends take #{to_sec(time)} sec.\n") + IO.puts("Making friends took #{to_sec(time)} sec.\n") end def make_friends(%User{} = main_user, %User{} = user) do diff --git a/benchmarks/mix/tasks/pleroma/benchmarks/timelines.ex b/benchmarks/mix/tasks/pleroma/benchmarks/timelines.ex index dc6f3d3fc..9b7ac6111 100644 --- a/benchmarks/mix/tasks/pleroma/benchmarks/timelines.ex +++ b/benchmarks/mix/tasks/pleroma/benchmarks/timelines.ex @@ -1,9 +1,10 @@ defmodule Mix.Tasks.Pleroma.Benchmarks.Timelines do use Mix.Task - alias Pleroma.Repo - alias Pleroma.LoadTesting.Generator + + import Pleroma.LoadTesting.Helper, only: [clean_tables: 0] alias Pleroma.Web.CommonAPI + alias Plug.Conn def run(_args) do Mix.Pleroma.start_pleroma() @@ -11,7 +12,7 @@ def run(_args) do # Cleaning tables clean_tables() - [{:ok, user} | users] = Generator.generate_users(users_max: 1000) + [{:ok, user} | users] = Pleroma.LoadTesting.Users.generate_users(1000) # Let the user make 100 posts @@ -38,8 +39,8 @@ def run(_args) do "user timeline, no followers" => fn reading_user -> conn = Phoenix.ConnTest.build_conn() - |> Plug.Conn.assign(:user, reading_user) - |> Plug.Conn.assign(:skip_link_headers, true) + |> Conn.assign(:user, reading_user) + |> Conn.assign(:skip_link_headers, true) Pleroma.Web.MastodonAPI.AccountController.statuses(conn, %{"id" => user.id}) end @@ -56,8 +57,8 @@ def run(_args) do "user timeline, all following" => fn reading_user -> conn = Phoenix.ConnTest.build_conn() - |> Plug.Conn.assign(:user, reading_user) - |> Plug.Conn.assign(:skip_link_headers, true) + |> Conn.assign(:user, reading_user) + |> Conn.assign(:skip_link_headers, true) Pleroma.Web.MastodonAPI.AccountController.statuses(conn, %{"id" => user.id}) end @@ -66,11 +67,4 @@ def run(_args) do time: 60 ) end - - defp clean_tables do - IO.puts("Deleting old data...\n") - Ecto.Adapters.SQL.query!(Repo, "TRUNCATE users CASCADE;") - Ecto.Adapters.SQL.query!(Repo, "TRUNCATE activities CASCADE;") - Ecto.Adapters.SQL.query!(Repo, "TRUNCATE objects CASCADE;") - end end From 2afc7a9112fc11bc51abc2b65aea03d6d5045695 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 30 Mar 2020 12:16:45 +0300 Subject: [PATCH 130/188] changelog fix --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f393ea8eb..52e6c33f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - NodeInfo: `pleroma:api/v1/notifications:include_types_filter` to the `features` list. - Configuration: `:restrict_unauthenticated` setting, restrict access for unauthenticated users to timelines (public and federate), user profiles and statuses. +- New HTTP adapter [gun](https://github.com/ninenines/gun). Gun adapter requires minimum OTP version of 22.2 otherwise Pleroma won’t start. For hackney OTP update is not required.
API Changes - Mastodon API: Support for `include_types` in `/api/v1/notifications`. @@ -97,7 +98,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - User settings: Add _This account is a_ option. - A new users admin digest email - OAuth: admin scopes support (relevant setting: `[:auth, :enforce_oauth_admin_scope_usage]`). -- New HTTP adapter [gun](https://github.com/ninenines/gun). Gun adapter requires minimum OTP version of 22.2 otherwise Pleroma won’t start. For hackney OTP update is not required. - Add an option `authorized_fetch_mode` to require HTTP signatures for AP fetches. - ActivityPub: support for `replies` collection (output for outgoing federation & fetching on incoming federation). - Mix task to refresh counter cache (`mix pleroma.refresh_counter_cache`) From 1fcdcb12a717fa3dbd54a5c3778bd216df6449ad Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 30 Mar 2020 12:47:12 +0300 Subject: [PATCH 131/188] updating gun with bug fix https://github.com/ninenines/gun/issues/222 --- lib/pleroma/pool/connections.ex | 31 +++++++++++-------------------- mix.exs | 2 +- mix.lock | 2 +- 3 files changed, 13 insertions(+), 22 deletions(-) diff --git a/lib/pleroma/pool/connections.ex b/lib/pleroma/pool/connections.ex index 91102faf7..4d4ba913c 100644 --- a/lib/pleroma/pool/connections.ex +++ b/lib/pleroma/pool/connections.ex @@ -167,29 +167,20 @@ defp sort_conns({_, c1}, {_, c2}) do c1.crf <= c2.crf and c1.last_reference <= c2.last_reference end - defp find_conn_from_gun_info(conns, pid) do - # TODO: temp fix for gun MatchError https://github.com/ninenines/gun/issues/222 - # TODO: REMOVE LATER - try do - %{origin_host: host, origin_scheme: scheme, origin_port: port} = Gun.info(pid) - - host = - case :inet.ntoa(host) do - {:error, :einval} -> host - ip -> ip - end - - key = "#{scheme}:#{host}:#{port}" - find_conn(conns, pid, key) - rescue - MatcheError -> find_conn(conns, pid) - end - end - @impl true def handle_info({:gun_up, conn_pid, _protocol}, state) do + %{origin_host: host, origin_scheme: scheme, origin_port: port} = Gun.info(conn_pid) + + host = + case :inet.ntoa(host) do + {:error, :einval} -> host + ip -> ip + end + + key = "#{scheme}:#{host}:#{port}" + state = - with {key, conn} <- find_conn_from_gun_info(state.conns, conn_pid), + with {key, conn} <- find_conn(state.conns, conn_pid, key), {true, key} <- {Process.alive?(conn_pid), key} do put_in(state.conns[key], %{ conn diff --git a/mix.exs b/mix.exs index 77d043d37..87c025d89 100644 --- a/mix.exs +++ b/mix.exs @@ -127,7 +127,7 @@ defp deps do {:castore, "~> 0.1"}, {:cowlib, "~> 2.8", override: true}, {:gun, - github: "ninenines/gun", ref: "bd6425ab87428cf4c95f4d23e0a48fd065fbd714", override: true}, + github: "ninenines/gun", ref: "e1a69b36b180a574c0ac314ced9613fdd52312cc", override: true}, {:jason, "~> 1.0"}, {:mogrify, "~> 0.6.1"}, {:ex_aws, "~> 2.1"}, diff --git a/mix.lock b/mix.lock index b791dccc4..6cca578d6 100644 --- a/mix.lock +++ b/mix.lock @@ -47,7 +47,7 @@ "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", "bd6425ab87428cf4c95f4d23e0a48fd065fbd714", [ref: "bd6425ab87428cf4c95f4d23e0a48fd065fbd714"]}, + "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"}, From b607ae1a1c0ef6557094ec0fb10ba2d19d621f7f Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 30 Mar 2020 13:50:00 +0300 Subject: [PATCH 132/188] removing grouped reports admin api endpoint --- lib/pleroma/web/activity_pub/utils.ex | 96 --------- .../web/admin_api/admin_api_controller.ex | 8 - .../web/admin_api/views/report_view.ex | 28 +-- lib/pleroma/web/router.ex | 1 - .../admin_api/admin_api_controller_test.exs | 203 ------------------ 5 files changed, 1 insertion(+), 335 deletions(-) diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index c65bbed67..2d685ecc0 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -795,102 +795,6 @@ def get_reports(params, page, page_size) do ActivityPub.fetch_activities([], params, :offset) end - def parse_report_group(activity) do - reports = get_reports_by_status_id(activity["id"]) - max_date = Enum.max_by(reports, &NaiveDateTime.from_iso8601!(&1.data["published"])) - actors = Enum.map(reports, & &1.user_actor) - [%{data: %{"object" => [account_id | _]}} | _] = reports - - account = - AccountView.render("show.json", %{ - user: User.get_by_ap_id(account_id) - }) - - status = get_status_data(activity) - - %{ - date: max_date.data["published"], - account: account, - status: status, - actors: Enum.uniq(actors), - reports: reports - } - end - - defp get_status_data(status) do - case status["deleted"] do - true -> - %{ - "id" => status["id"], - "deleted" => true - } - - _ -> - Activity.get_by_ap_id(status["id"]) - end - end - - def get_reports_by_status_id(ap_id) do - from(a in Activity, - where: fragment("(?)->>'type' = 'Flag'", a.data), - where: fragment("(?)->'object' @> ?", a.data, ^[%{id: ap_id}]), - or_where: fragment("(?)->'object' @> ?", a.data, ^[ap_id]) - ) - |> Activity.with_preloaded_user_actor() - |> Repo.all() - end - - @spec get_reports_grouped_by_status([String.t()]) :: %{ - required(:groups) => [ - %{ - required(:date) => String.t(), - required(:account) => %{}, - required(:status) => %{}, - required(:actors) => [%User{}], - required(:reports) => [%Activity{}] - } - ] - } - def get_reports_grouped_by_status(activity_ids) do - parsed_groups = - activity_ids - |> Enum.map(fn id -> - id - |> build_flag_object() - |> parse_report_group() - end) - - %{ - groups: parsed_groups - } - end - - @spec get_reported_activities() :: [ - %{ - required(:activity) => String.t(), - required(:date) => String.t() - } - ] - def get_reported_activities do - reported_activities_query = - from(a in Activity, - where: fragment("(?)->>'type' = 'Flag'", a.data), - select: %{ - activity: fragment("jsonb_array_elements((? #- '{object,0}')->'object')", a.data) - }, - group_by: fragment("activity") - ) - - from(a in subquery(reported_activities_query), - distinct: true, - select: %{ - id: fragment("COALESCE(?->>'id'::text, ? #>> '{}')", a.activity, a.activity) - } - ) - |> Repo.all() - |> Enum.map(& &1.id) - end - def update_report_state(%Activity{} = activity, state) when state in @strip_status_report_states do {:ok, stripped_activity} = strip_report_status_data(activity) diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 0368df1e9..ca5439920 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -715,14 +715,6 @@ def list_reports(conn, params) do |> render("index.json", %{reports: reports}) end - def list_grouped_reports(conn, _params) do - statuses = Utils.get_reported_activities() - - conn - |> put_view(ReportView) - |> render("index_grouped.json", Utils.get_reports_grouped_by_status(statuses)) - end - def report_show(conn, %{"id" => id}) do with %Activity{} = report <- Activity.get_by_id(id) do conn diff --git a/lib/pleroma/web/admin_api/views/report_view.ex b/lib/pleroma/web/admin_api/views/report_view.ex index fc8733ce8..ca0bcebc7 100644 --- a/lib/pleroma/web/admin_api/views/report_view.ex +++ b/lib/pleroma/web/admin_api/views/report_view.ex @@ -4,7 +4,7 @@ defmodule Pleroma.Web.AdminAPI.ReportView do use Pleroma.Web, :view - alias Pleroma.Activity + alias Pleroma.HTML alias Pleroma.User alias Pleroma.Web.AdminAPI.Report @@ -44,32 +44,6 @@ def render("show.json", %{report: report, user: user, account: account, statuses } end - def render("index_grouped.json", %{groups: groups}) do - reports = - Enum.map(groups, fn group -> - status = - case group.status do - %Activity{} = activity -> StatusView.render("show.json", %{activity: activity}) - _ -> group.status - end - - %{ - date: group[:date], - account: group[:account], - status: Map.put_new(status, "deleted", false), - actors: Enum.map(group[:actors], &merge_account_views/1), - reports: - group[:reports] - |> Enum.map(&Report.extract_report_info(&1)) - |> Enum.map(&render(__MODULE__, "show.json", &1)) - } - end) - - %{ - reports: reports - } - end - def render("index_notes.json", %{notes: notes}) when is_list(notes) do Enum.map(notes, &render(__MODULE__, "show_note.json", &1)) end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index a22f744c1..5a0902739 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -186,7 +186,6 @@ defmodule Pleroma.Web.Router do patch("/users/resend_confirmation_email", AdminAPIController, :resend_confirmation_email) get("/reports", AdminAPIController, :list_reports) - get("/grouped_reports", AdminAPIController, :list_grouped_reports) get("/reports/:id", AdminAPIController, :report_show) patch("/reports", AdminAPIController, :reports_update) post("/reports/:id/notes", AdminAPIController, :report_notes_create) diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index c9e228cc8..ea0c92502 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -21,7 +21,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do alias Pleroma.UserInviteToken alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.CommonAPI - alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MediaProxy setup_all do @@ -1586,208 +1585,6 @@ test "returns 403 when requested by anonymous" do end end - describe "GET /api/pleroma/admin/grouped_reports" do - setup do - [reporter, target_user] = insert_pair(:user) - - date1 = (DateTime.to_unix(DateTime.utc_now()) + 1000) |> DateTime.from_unix!() - date2 = (DateTime.to_unix(DateTime.utc_now()) + 2000) |> DateTime.from_unix!() - date3 = (DateTime.to_unix(DateTime.utc_now()) + 3000) |> DateTime.from_unix!() - - first_status = - insert(:note_activity, user: target_user, data_attrs: %{"published" => date1}) - - second_status = - insert(:note_activity, user: target_user, data_attrs: %{"published" => date2}) - - third_status = - insert(:note_activity, user: target_user, data_attrs: %{"published" => date3}) - - {:ok, first_report} = - CommonAPI.report(reporter, %{ - "account_id" => target_user.id, - "status_ids" => [first_status.id, second_status.id, third_status.id] - }) - - {:ok, second_report} = - CommonAPI.report(reporter, %{ - "account_id" => target_user.id, - "status_ids" => [first_status.id, second_status.id] - }) - - {:ok, third_report} = - CommonAPI.report(reporter, %{ - "account_id" => target_user.id, - "status_ids" => [first_status.id] - }) - - %{ - first_status: Activity.get_by_ap_id_with_object(first_status.data["id"]), - second_status: Activity.get_by_ap_id_with_object(second_status.data["id"]), - third_status: Activity.get_by_ap_id_with_object(third_status.data["id"]), - first_report: first_report, - first_status_reports: [first_report, second_report, third_report], - second_status_reports: [first_report, second_report], - third_status_reports: [first_report], - target_user: target_user, - reporter: reporter - } - end - - test "returns reports grouped by status", %{ - conn: conn, - first_status: first_status, - second_status: second_status, - third_status: third_status, - first_status_reports: first_status_reports, - second_status_reports: second_status_reports, - third_status_reports: third_status_reports, - target_user: target_user, - reporter: reporter - } do - response = - conn - |> get("/api/pleroma/admin/grouped_reports") - |> json_response(:ok) - - assert length(response["reports"]) == 3 - - first_group = Enum.find(response["reports"], &(&1["status"]["id"] == first_status.id)) - - second_group = Enum.find(response["reports"], &(&1["status"]["id"] == second_status.id)) - - third_group = Enum.find(response["reports"], &(&1["status"]["id"] == third_status.id)) - - assert length(first_group["reports"]) == 3 - assert length(second_group["reports"]) == 2 - assert length(third_group["reports"]) == 1 - - assert first_group["date"] == - Enum.max_by(first_status_reports, fn act -> - NaiveDateTime.from_iso8601!(act.data["published"]) - end).data["published"] - - assert first_group["status"] == - Map.put( - stringify_keys(StatusView.render("show.json", %{activity: first_status})), - "deleted", - false - ) - - assert(first_group["account"]["id"] == target_user.id) - - assert length(first_group["actors"]) == 1 - assert hd(first_group["actors"])["id"] == reporter.id - - assert Enum.map(first_group["reports"], & &1["id"]) -- - Enum.map(first_status_reports, & &1.id) == [] - - assert second_group["date"] == - Enum.max_by(second_status_reports, fn act -> - NaiveDateTime.from_iso8601!(act.data["published"]) - end).data["published"] - - assert second_group["status"] == - Map.put( - stringify_keys(StatusView.render("show.json", %{activity: second_status})), - "deleted", - false - ) - - assert second_group["account"]["id"] == target_user.id - - assert length(second_group["actors"]) == 1 - assert hd(second_group["actors"])["id"] == reporter.id - - assert Enum.map(second_group["reports"], & &1["id"]) -- - Enum.map(second_status_reports, & &1.id) == [] - - assert third_group["date"] == - Enum.max_by(third_status_reports, fn act -> - NaiveDateTime.from_iso8601!(act.data["published"]) - end).data["published"] - - assert third_group["status"] == - Map.put( - stringify_keys(StatusView.render("show.json", %{activity: third_status})), - "deleted", - false - ) - - assert third_group["account"]["id"] == target_user.id - - assert length(third_group["actors"]) == 1 - assert hd(third_group["actors"])["id"] == reporter.id - - assert Enum.map(third_group["reports"], & &1["id"]) -- - Enum.map(third_status_reports, & &1.id) == [] - end - - test "reopened report renders status data", %{ - conn: conn, - first_report: first_report, - first_status: first_status - } do - {:ok, _} = CommonAPI.update_report_state(first_report.id, "resolved") - - response = - conn - |> get("/api/pleroma/admin/grouped_reports") - |> json_response(:ok) - - first_group = Enum.find(response["reports"], &(&1["status"]["id"] == first_status.id)) - - assert first_group["status"] == - Map.put( - stringify_keys(StatusView.render("show.json", %{activity: first_status})), - "deleted", - false - ) - end - - test "reopened report does not render status data if status has been deleted", %{ - conn: conn, - first_report: first_report, - first_status: first_status, - target_user: target_user - } do - {:ok, _} = CommonAPI.update_report_state(first_report.id, "resolved") - {:ok, _} = CommonAPI.delete(first_status.id, target_user) - - refute Activity.get_by_ap_id(first_status.id) - - response = - conn - |> get("/api/pleroma/admin/grouped_reports") - |> json_response(:ok) - - assert Enum.find(response["reports"], &(&1["status"]["deleted"] == true))["status"][ - "deleted" - ] == true - - assert length(Enum.filter(response["reports"], &(&1["status"]["deleted"] == false))) == 2 - end - - test "account not empty if status was deleted", %{ - conn: conn, - first_report: first_report, - first_status: first_status, - target_user: target_user - } do - {:ok, _} = CommonAPI.update_report_state(first_report.id, "resolved") - {:ok, _} = CommonAPI.delete(first_status.id, target_user) - - refute Activity.get_by_ap_id(first_status.id) - - response = - conn - |> get("/api/pleroma/admin/grouped_reports") - |> json_response(:ok) - - assert Enum.find(response["reports"], &(&1["status"]["deleted"] == true))["account"] - end - end - describe "PUT /api/pleroma/admin/statuses/:id" do setup do activity = insert(:note_activity) From f6835333be745cd411b5d2571c304fc7a16d645e Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 31 Mar 2020 12:55:25 +0000 Subject: [PATCH 133/188] Apply suggestion to lib/pleroma/web/activity_pub/transmogrifier.ex --- lib/pleroma/web/activity_pub/transmogrifier.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index dbb14e9aa..23148b2a0 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -615,8 +615,7 @@ def handle_incoming(%{"type" => "Like"} = data, _options) do with {_, {:ok, cast_data_sym}} <- {:casting_data, data |> LikeValidator.cast_data() |> Ecto.Changeset.apply_action(:insert)}, - {_, cast_data} <- - {:stringify_keys, ObjectValidator.stringify_keys(cast_data_sym |> Map.from_struct())}, + cast_data = ObjectValidator.stringify_keys(Map.from_struct(cast_data_sym)), :ok <- ObjectValidator.fetch_actor_and_object(cast_data), {_, {:ok, cast_data}} <- {:maybe_add_context, maybe_add_context_from_object(cast_data)}, {_, {:ok, cast_data}} <- From d191b0942f64a32a2bf450318fac85981aa17c83 Mon Sep 17 00:00:00 2001 From: kPherox Date: Tue, 31 Mar 2020 22:48:42 +0900 Subject: [PATCH 134/188] Remove no longer used function --- lib/pleroma/user.ex | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index d9aa54057..6644d6b66 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1983,17 +1983,6 @@ def fields(%{fields: nil}), do: [] def fields(%{fields: fields}), do: fields - def sanitized_fields(%User{} = user) do - user - |> User.fields() - |> Enum.map(fn %{"name" => name, "value" => value} -> - %{ - "name" => name, - "value" => Pleroma.HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly) - } - end) - end - def validate_fields(changeset, remote? \\ false) do limit_name = if remote?, do: :max_remote_account_fields, else: :max_account_fields limit = Pleroma.Config.get([:instance, limit_name], 0) From 643f15e77b7cdaaf2c22a876c98e5680edc32dc3 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 31 Mar 2020 16:11:38 +0200 Subject: [PATCH 135/188] Validators: ObjectID is an http uri. --- .../object_validators/types/object.ex | 16 ++++++-- .../types/object_id_test.exs | 38 +++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 test/web/activity_pub/object_validators/types/object_id_test.exs diff --git a/lib/pleroma/web/activity_pub/object_validators/types/object.ex b/lib/pleroma/web/activity_pub/object_validators/types/object.ex index 92fc13ba8..8e70effe4 100644 --- a/lib/pleroma/web/activity_pub/object_validators/types/object.ex +++ b/lib/pleroma/web/activity_pub/object_validators/types/object.ex @@ -4,12 +4,20 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID do def type, do: :string def cast(object) when is_binary(object) do - {:ok, object} + with %URI{ + scheme: scheme, + host: host + } + when scheme in ["https", "http"] and not is_nil(host) <- + URI.parse(object) do + {:ok, object} + else + _ -> + :error + end end - def cast(%{"id" => object}) when is_binary(object) do - {:ok, object} - end + def cast(%{"id" => object}), do: cast(object) def cast(_) do :error diff --git a/test/web/activity_pub/object_validators/types/object_id_test.exs b/test/web/activity_pub/object_validators/types/object_id_test.exs new file mode 100644 index 000000000..f4c5ed1dc --- /dev/null +++ b/test/web/activity_pub/object_validators/types/object_id_test.exs @@ -0,0 +1,38 @@ +defmodule Pleroma.Web.ObjectValidators.Types.ObjectIDTest do + alias Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID + use Pleroma.DataCase + + @uris [ + "http://lain.com/users/lain", + "http://lain.com", + "https://lain.com/object/1" + ] + + @non_uris [ + "https://", + "rin" + ] + + test "it rejects integers" do + assert :error == ObjectID.cast(1) + end + + test "it accepts http uris" do + Enum.each(@uris, fn uri -> + assert {:ok, uri} == ObjectID.cast(uri) + end) + end + + test "it accepts an object with a nested uri id" do + Enum.each(@uris, fn uri -> + assert {:ok, uri} == ObjectID.cast(%{"id" => uri}) + end) + end + + test "it rejects non-uri strings" do + Enum.each(@non_uris, fn non_uri -> + assert :error == ObjectID.cast(non_uri) + assert :error == ObjectID.cast(%{"id" => non_uri}) + end) + end +end From df5f89c0d6d8d385434d5d8a51719fa41631d7b2 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 31 Mar 2020 18:22:25 +0300 Subject: [PATCH 136/188] test for default features and changelog entry --- CHANGELOG.md | 1 + test/web/node_info_test.exs | 92 +++++++++++++++++++------------------ 2 files changed, 49 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 350e03894..747d84d48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - NodeInfo: `pleroma:api/v1/notifications:include_types_filter` to the `features` list. +- NodeInfo: `pleroma_emoji_reactions` to the `features` list. - Configuration: `:restrict_unauthenticated` setting, restrict access for unauthenticated users to timelines (public and federate), user profiles and statuses.
API Changes diff --git a/test/web/node_info_test.exs b/test/web/node_info_test.exs index e5eebced1..9bcc07b37 100644 --- a/test/web/node_info_test.exs +++ b/test/web/node_info_test.exs @@ -7,6 +7,8 @@ defmodule Pleroma.Web.NodeInfoTest do import Pleroma.Factory + alias Pleroma.Config + setup do: clear_config([:mrf_simple]) setup do: clear_config(:instance) @@ -47,7 +49,7 @@ test "nodeinfo shows restricted nicknames", %{conn: conn} do assert result = json_response(conn, 200) - assert Pleroma.Config.get([Pleroma.User, :restricted_nicknames]) == + assert Config.get([Pleroma.User, :restricted_nicknames]) == result["metadata"]["restrictedNicknames"] end @@ -65,10 +67,10 @@ test "returns software.repository field in nodeinfo 2.1", %{conn: conn} do end test "returns fieldsLimits field", %{conn: conn} do - Pleroma.Config.put([:instance, :max_account_fields], 10) - Pleroma.Config.put([:instance, :max_remote_account_fields], 15) - Pleroma.Config.put([:instance, :account_field_name_length], 255) - Pleroma.Config.put([:instance, :account_field_value_length], 2048) + Config.put([:instance, :max_account_fields], 10) + Config.put([:instance, :max_remote_account_fields], 15) + Config.put([:instance, :account_field_name_length], 255) + Config.put([:instance, :account_field_value_length], 2048) response = conn @@ -82,8 +84,8 @@ test "returns fieldsLimits field", %{conn: conn} do end test "it returns the safe_dm_mentions feature if enabled", %{conn: conn} do - option = Pleroma.Config.get([:instance, :safe_dm_mentions]) - Pleroma.Config.put([:instance, :safe_dm_mentions], true) + option = Config.get([:instance, :safe_dm_mentions]) + Config.put([:instance, :safe_dm_mentions], true) response = conn @@ -92,7 +94,7 @@ test "it returns the safe_dm_mentions feature if enabled", %{conn: conn} do assert "safe_dm_mentions" in response["metadata"]["features"] - Pleroma.Config.put([:instance, :safe_dm_mentions], false) + Config.put([:instance, :safe_dm_mentions], false) response = conn @@ -101,14 +103,14 @@ test "it returns the safe_dm_mentions feature if enabled", %{conn: conn} do refute "safe_dm_mentions" in response["metadata"]["features"] - Pleroma.Config.put([:instance, :safe_dm_mentions], option) + Config.put([:instance, :safe_dm_mentions], option) end describe "`metadata/federation/enabled`" do setup do: clear_config([:instance, :federating]) test "it shows if federation is enabled/disabled", %{conn: conn} do - Pleroma.Config.put([:instance, :federating], true) + Config.put([:instance, :federating], true) response = conn @@ -117,7 +119,7 @@ test "it shows if federation is enabled/disabled", %{conn: conn} do assert response["metadata"]["federation"]["enabled"] == true - Pleroma.Config.put([:instance, :federating], false) + Config.put([:instance, :federating], false) response = conn @@ -134,31 +136,33 @@ test "it shows default features flags", %{conn: conn} do |> get("/nodeinfo/2.1.json") |> json_response(:ok) - assert response["metadata"]["features"] -- - [ - "pleroma_api", - "mastodon_api", - "mastodon_api_streaming", - "polls", - "pleroma_explicit_addressing", - "shareable_emoji_packs", - "multifetch", - "chat", - "relay", - "pleroma_emoji_reactions", - "pleroma:api/v1/notifications:include_types_filter" - ] == [] + default_features = [ + "pleroma_api", + "mastodon_api", + "mastodon_api_streaming", + "polls", + "pleroma_explicit_addressing", + "shareable_emoji_packs", + "multifetch", + "pleroma_emoji_reactions", + "pleroma:api/v1/notifications:include_types_filter" + ] + + assert MapSet.subset?( + MapSet.new(default_features), + MapSet.new(response["metadata"]["features"]) + ) end test "it shows MRF transparency data if enabled", %{conn: conn} do - config = Pleroma.Config.get([:instance, :rewrite_policy]) - Pleroma.Config.put([:instance, :rewrite_policy], [Pleroma.Web.ActivityPub.MRF.SimplePolicy]) + config = Config.get([:instance, :rewrite_policy]) + Config.put([:instance, :rewrite_policy], [Pleroma.Web.ActivityPub.MRF.SimplePolicy]) - option = Pleroma.Config.get([:instance, :mrf_transparency]) - Pleroma.Config.put([:instance, :mrf_transparency], true) + option = Config.get([:instance, :mrf_transparency]) + Config.put([:instance, :mrf_transparency], true) simple_config = %{"reject" => ["example.com"]} - Pleroma.Config.put(:mrf_simple, simple_config) + Config.put(:mrf_simple, simple_config) response = conn @@ -167,25 +171,25 @@ test "it shows MRF transparency data if enabled", %{conn: conn} do assert response["metadata"]["federation"]["mrf_simple"] == simple_config - Pleroma.Config.put([:instance, :rewrite_policy], config) - Pleroma.Config.put([:instance, :mrf_transparency], option) - Pleroma.Config.put(:mrf_simple, %{}) + Config.put([:instance, :rewrite_policy], config) + Config.put([:instance, :mrf_transparency], option) + Config.put(:mrf_simple, %{}) end test "it performs exclusions from MRF transparency data if configured", %{conn: conn} do - config = Pleroma.Config.get([:instance, :rewrite_policy]) - Pleroma.Config.put([:instance, :rewrite_policy], [Pleroma.Web.ActivityPub.MRF.SimplePolicy]) + config = Config.get([:instance, :rewrite_policy]) + Config.put([:instance, :rewrite_policy], [Pleroma.Web.ActivityPub.MRF.SimplePolicy]) - option = Pleroma.Config.get([:instance, :mrf_transparency]) - Pleroma.Config.put([:instance, :mrf_transparency], true) + option = Config.get([:instance, :mrf_transparency]) + Config.put([:instance, :mrf_transparency], true) - exclusions = Pleroma.Config.get([:instance, :mrf_transparency_exclusions]) - Pleroma.Config.put([:instance, :mrf_transparency_exclusions], ["other.site"]) + exclusions = Config.get([:instance, :mrf_transparency_exclusions]) + Config.put([:instance, :mrf_transparency_exclusions], ["other.site"]) simple_config = %{"reject" => ["example.com", "other.site"]} expected_config = %{"reject" => ["example.com"]} - Pleroma.Config.put(:mrf_simple, simple_config) + Config.put(:mrf_simple, simple_config) response = conn @@ -195,9 +199,9 @@ test "it performs exclusions from MRF transparency data if configured", %{conn: assert response["metadata"]["federation"]["mrf_simple"] == expected_config assert response["metadata"]["federation"]["exclusions"] == true - Pleroma.Config.put([:instance, :rewrite_policy], config) - Pleroma.Config.put([:instance, :mrf_transparency], option) - Pleroma.Config.put([:instance, :mrf_transparency_exclusions], exclusions) - Pleroma.Config.put(:mrf_simple, %{}) + Config.put([:instance, :rewrite_policy], config) + Config.put([:instance, :mrf_transparency], option) + Config.put([:instance, :mrf_transparency_exclusions], exclusions) + Config.put(:mrf_simple, %{}) end end From 7af0959a07ebd5f8242704658ccb770d86fdb4c6 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 31 Mar 2020 18:30:19 +0300 Subject: [PATCH 137/188] updating docs --- docs/API/pleroma_api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index 12e63ef9f..90c43c356 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -431,7 +431,7 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa # Emoji Reactions -Emoji reactions work a lot like favourites do. They make it possible to react to a post with a single emoji character. +Emoji reactions work a lot like favourites do. They make it possible to react to a post with a single emoji character. To detect the presence of this feature, you can check `pleroma_emoji_reactions` entry in the features list of nodeinfo. ## `PUT /api/v1/pleroma/statuses/:id/reactions/:emoji` ### React to a post with a unicode emoji From aebec1bac9831da2bed5ee571225d92dc99a5d59 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 31 Mar 2020 17:47:34 +0200 Subject: [PATCH 138/188] Validator Test: Small refactor. --- .../object_validators/types/object_id_test.exs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/test/web/activity_pub/object_validators/types/object_id_test.exs b/test/web/activity_pub/object_validators/types/object_id_test.exs index f4c5ed1dc..834213182 100644 --- a/test/web/activity_pub/object_validators/types/object_id_test.exs +++ b/test/web/activity_pub/object_validators/types/object_id_test.exs @@ -10,13 +10,12 @@ defmodule Pleroma.Web.ObjectValidators.Types.ObjectIDTest do @non_uris [ "https://", - "rin" + "rin", + 1, + :x, + %{"1" => 2} ] - test "it rejects integers" do - assert :error == ObjectID.cast(1) - end - test "it accepts http uris" do Enum.each(@uris, fn uri -> assert {:ok, uri} == ObjectID.cast(uri) From 057438a657eaadb963e006b84b890ae4f8441808 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 31 Mar 2020 17:56:05 +0200 Subject: [PATCH 139/188] CommonAPI: DRY up a bit. --- lib/pleroma/web/common_api/common_api.ex | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index f882f9fcb..74adcca55 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -112,8 +112,22 @@ def unrepeat(id_or_ap_id, user) do end end - @spec favorite(User.t(), binary()) :: {:ok, Activity.t()} | {:error, any()} + @spec favorite(User.t(), binary()) :: {:ok, Activity.t() | :already_liked} | {:error, any()} def favorite(%User{} = user, id) do + case favorite_helper(user, id) do + {:ok, _} = res -> + res + + {:error, :not_found} = res -> + res + + {:error, e} -> + Logger.error("Could not favorite #{id}. Error: #{inspect(e, pretty: true)}") + {:error, dgettext("errors", "Could not favorite")} + end + end + + def favorite_helper(user, id) do with {_, %Activity{object: object}} <- {:find_object, Activity.get_by_id_with_object(id)}, {_, {:ok, like_object, meta}} <- {:build_object, Builder.like(user, object)}, {_, {:ok, %Activity{} = activity, _meta}} <- @@ -138,13 +152,11 @@ def favorite(%User{} = user, id) do if {:object, {"already liked by this actor", []}} in changeset.errors do {:ok, :already_liked} else - Logger.error("Could not favorite #{id}. Error: #{inspect(e, pretty: true)}") - {:error, dgettext("errors", "Could not favorite"), e} + {:error, e} end e -> - Logger.error("Could not favorite #{id}. Error: #{inspect(e, pretty: true)}") - {:error, dgettext("errors", "Could not favorite"), e} + {:error, e} end end From 0be1fa0a8695df87a8b22279b885956943e33796 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 31 Mar 2020 17:00:48 +0000 Subject: [PATCH 140/188] Apply suggestion to lib/pleroma/web/activity_pub/transmogrifier.ex --- lib/pleroma/web/activity_pub/transmogrifier.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 23148b2a0..fb41ec8e9 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -1291,6 +1291,6 @@ defp maybe_add_recipients_from_object(%{"object" => object} = data) do end defp maybe_add_recipients_from_object(_) do - {:error, "No referenced object"} + {:error, :no_object} end end From 288f2b5a7c728959d43205a97d5225b34b5b8161 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 31 Mar 2020 17:00:55 +0000 Subject: [PATCH 141/188] Apply suggestion to lib/pleroma/web/activity_pub/transmogrifier.ex --- lib/pleroma/web/activity_pub/transmogrifier.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index fb41ec8e9..a3529f09b 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -1267,7 +1267,7 @@ defp maybe_add_context_from_object(%{"object" => object} = data) when is_binary( end defp maybe_add_context_from_object(_) do - {:error, "No referenced object"} + {:error, :no_context} end defp maybe_add_recipients_from_object(%{"object" => object} = data) do From ecac57732a063c1ad01aeb5aa4eb9853b6f904e9 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 31 Mar 2020 19:16:45 +0200 Subject: [PATCH 142/188] Transmogrifier: Only add context if it really is onne. --- lib/pleroma/web/activity_pub/transmogrifier.ex | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index a3529f09b..f82142979 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -1255,14 +1255,11 @@ defp maybe_add_context_from_object(%{"context" => context} = data) when is_binar do: {:ok, data} defp maybe_add_context_from_object(%{"object" => object} = data) when is_binary(object) do - if object = Object.normalize(object) do - data = - data - |> Map.put("context", object.data["context"]) - - {:ok, data} + with %{data: %{"context" => context}} when is_binary(context) <- Object.normalize(object) do + {:ok, Map.put(data, "context", context)} else - {:error, "No context on referenced object"} + _ -> + {:error, :no_context} end end From 1b323ce1c668c6a26617a05dcc12ee255c764e88 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 31 Mar 2020 17:28:18 +0000 Subject: [PATCH 143/188] Apply suggestion to lib/pleroma/web/activity_pub/transmogrifier.ex --- .../web/activity_pub/transmogrifier.ex | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index f82142979..a18ece6e7 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -1267,24 +1267,19 @@ defp maybe_add_context_from_object(_) do {:error, :no_context} end - defp maybe_add_recipients_from_object(%{"object" => object} = data) do - to = data["to"] || [] - cc = data["cc"] || [] + defp maybe_add_recipients_from_object(%{"to" => [_ | _], "cc" => [_ | _]} = data), do: {:ok, data} - if to == [] && cc == [] do - if object = Object.normalize(object) do + defp maybe_add_recipients_from_object(%{"object" => object} = data) do + case Object.normalize(object) do + %{data: {"actor" => actor}} -> data = data - |> Map.put("to", [object.data["actor"]]) - |> Map.put("cc", cc) + |> Map.put("to", [actor]) + |> Map.put("cc", data["cc"] || []) {:ok, data} - else - {:error, "No actor on referenced object"} - end - else - {:ok, data} - end + nil -> {:error, :no_object} + _ -> {:error, :no_actor} end defp maybe_add_recipients_from_object(_) do From c982093cc2f538e8ef9dde365e163a944c6cb6d0 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 31 Mar 2020 19:33:41 +0200 Subject: [PATCH 144/188] Transmogrifier: Fix BAD code by RINPATCH --- lib/pleroma/web/activity_pub/transmogrifier.ex | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index a18ece6e7..a4b385cd5 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -1267,19 +1267,25 @@ defp maybe_add_context_from_object(_) do {:error, :no_context} end - defp maybe_add_recipients_from_object(%{"to" => [_ | _], "cc" => [_ | _]} = data), do: {:ok, data} + defp maybe_add_recipients_from_object(%{"to" => [_ | _], "cc" => [_ | _]} = data), + do: {:ok, data} defp maybe_add_recipients_from_object(%{"object" => object} = data) do case Object.normalize(object) do - %{data: {"actor" => actor}} -> + %{data: %{"actor" => actor}} -> data = data |> Map.put("to", [actor]) |> Map.put("cc", data["cc"] || []) {:ok, data} - nil -> {:error, :no_object} - _ -> {:error, :no_actor} + + nil -> + {:error, :no_object} + + _ -> + {:error, :no_actor} + end end defp maybe_add_recipients_from_object(_) do From dbf9d719f98770056ac906b3087e7ed501cd64e6 Mon Sep 17 00:00:00 2001 From: kPherox Date: Wed, 1 Apr 2020 00:05:13 +0900 Subject: [PATCH 145/188] split test for update profile fields --- .../update_credentials_test.exs | 98 ++++++++++--------- 1 file changed, 53 insertions(+), 45 deletions(-) diff --git a/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs index b693c1a47..8687d7995 100644 --- a/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs +++ b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs @@ -273,7 +273,7 @@ test "updates profile emojos", %{user: user, conn: conn} do test "update fields", %{conn: conn} do fields = [ %{"name" => "foo", "value" => ""}, - %{"name" => "link", "value" => "cofe.io"} + %{"name" => "link.io", "value" => "cofe.io"} ] account_data = @@ -283,7 +283,10 @@ test "update fields", %{conn: conn} do assert account_data["fields"] == [ %{"name" => "foo", "value" => "bar"}, - %{"name" => "link", "value" => ~S(cofe.io)} + %{ + "name" => "link.io", + "value" => ~S(cofe.io) + } ] assert account_data["source"]["fields"] == [ @@ -291,14 +294,16 @@ test "update fields", %{conn: conn} do "name" => "foo", "value" => "" }, - %{"name" => "link", "value" => "cofe.io"} + %{"name" => "link.io", "value" => "cofe.io"} ] + end + test "update fields by urlencoded", %{conn: conn} do fields = [ "fields_attributes[1][name]=link", - "fields_attributes[1][value]=cofe.io", - "fields_attributes[0][name]=foo", + "fields_attributes[1][value]=http://cofe.io", + "fields_attributes[0][name]=foo", "fields_attributes[0][value]=bar" ] |> Enum.join("&") @@ -310,51 +315,20 @@ test "update fields", %{conn: conn} do |> json_response(200) assert account["fields"] == [ - %{"name" => "foo", "value" => "bar"}, - %{"name" => "link", "value" => ~S(cofe.io)} + %{"name" => "foo", "value" => "bar"}, + %{ + "name" => "link", + "value" => ~S(http://cofe.io) + } ] assert account["source"]["fields"] == [ - %{ - "name" => "foo", - "value" => "bar" - }, - %{"name" => "link", "value" => "cofe.io"} + %{"name" => "foo", "value" => "bar"}, + %{"name" => "link", "value" => "http://cofe.io"} ] + end - name_limit = Pleroma.Config.get([:instance, :account_field_name_length]) - value_limit = Pleroma.Config.get([:instance, :account_field_value_length]) - - long_value = Enum.map(0..value_limit, fn _ -> "x" end) |> Enum.join() - - fields = [%{"name" => "foo", "value" => long_value}] - - assert %{"error" => "Invalid request"} == - conn - |> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields}) - |> json_response(403) - - long_name = Enum.map(0..name_limit, fn _ -> "x" end) |> Enum.join() - - fields = [%{"name" => long_name, "value" => "bar"}] - - assert %{"error" => "Invalid request"} == - conn - |> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields}) - |> json_response(403) - - Pleroma.Config.put([:instance, :max_account_fields], 1) - - fields = [ - %{"name" => "foo", "value" => "bar"}, - %{"name" => "link", "value" => "cofe.io"} - ] - - assert %{"error" => "Invalid request"} == - conn - |> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields}) - |> json_response(403) - + test "update fields with empty name", %{conn: conn} do fields = [ %{"name" => "foo", "value" => ""}, %{"name" => "", "value" => "bar"} @@ -369,5 +343,39 @@ test "update fields", %{conn: conn} do %{"name" => "foo", "value" => ""} ] end + + test "update fields when invalid request", %{conn: conn} do + name_limit = Pleroma.Config.get([:instance, :account_field_name_length]) + value_limit = Pleroma.Config.get([:instance, :account_field_value_length]) + + long_name = Enum.map(0..name_limit, fn _ -> "x" end) |> Enum.join() + long_value = Enum.map(0..value_limit, fn _ -> "x" end) |> Enum.join() + + fields = [%{"name" => "foo", "value" => long_value}] + + assert %{"error" => "Invalid request"} == + conn + |> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields}) + |> json_response(403) + + fields = [%{"name" => long_name, "value" => "bar"}] + + assert %{"error" => "Invalid request"} == + conn + |> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields}) + |> json_response(403) + + Pleroma.Config.put([:instance, :max_account_fields], 1) + + fields = [ + %{"name" => "foo", "value" => "bar"}, + %{"name" => "link", "value" => "cofe.io"} + ] + + assert %{"error" => "Invalid request"} == + conn + |> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields}) + |> json_response(403) + end end end From 7408f003a663c5f634cabad963c0446ba54810bf Mon Sep 17 00:00:00 2001 From: kPherox Date: Tue, 31 Mar 2020 11:13:53 +0000 Subject: [PATCH 146/188] Use `Pleroma.Formatter.linkify` instead of `AutoLinker.link` --- lib/pleroma/user.ex | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 6644d6b66..c29935871 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -16,6 +16,7 @@ defmodule Pleroma.User do alias Pleroma.Conversation.Participation alias Pleroma.Delivery alias Pleroma.FollowingRelationship + alias Pleroma.Formatter alias Pleroma.HTML alias Pleroma.Keys alias Pleroma.Notification @@ -456,7 +457,7 @@ defp put_fields(changeset) do fields = raw_fields - |> Enum.map(fn f -> Map.update!(f, "value", &AutoLinker.link(&1)) end) + |> Enum.map(fn f -> Map.update!(f, "value", &parse_fields(&1)) end) changeset |> put_change(:raw_fields, raw_fields) @@ -466,6 +467,12 @@ defp put_fields(changeset) do end end + defp parse_fields(value) do + value + |> Formatter.linkify(mentions_format: :full) + |> elem(0) + end + defp put_change_if_present(changeset, map_field, value_function) do if value = get_change(changeset, map_field) do with {:ok, new_value} <- value_function.(value) do From 219d3aaa2d1fd2474a88ec40d7e6938741e7fc4b Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 31 Mar 2020 13:05:16 -0500 Subject: [PATCH 147/188] Update AdminFE build in preparation for Pleroma 2.0.2 --- .../{app.c836e084.css => app.85534e14.css} | Bin ...f.650c8e81.css => chunk-0d8f.d85f5a29.css} | Bin 3433 -> 3433 bytes ...a.3936457d.css => chunk-136a.f1130f8e.css} | Bin 4946 -> 4946 bytes priv/static/adminfe/chunk-13e9.98eaadba.css | Bin 0 -> 1071 bytes priv/static/adminfe/chunk-2b9c.feb61a2b.css | Bin 0 -> 5580 bytes priv/static/adminfe/chunk-46cf.a43e9415.css | Bin 1071 -> 0 bytes ...f.d45db7be.css => chunk-46ef.145de4f9.css} | Bin 1790 -> 1790 bytes priv/static/adminfe/chunk-4e7d.7aace723.css | Bin 5332 -> 0 bytes priv/static/adminfe/chunk-87b3.2affd602.css | Bin 9407 -> 0 bytes priv/static/adminfe/chunk-87b3.3c6ede9c.css | Bin 0 -> 9575 bytes ...f.cba3ae06.css => chunk-88c9.184084df.css} | Bin 5731 -> 5731 bytes ...7.4d39576f.css => chunk-cf57.26596375.css} | Bin 3221 -> 3244 bytes priv/static/adminfe/index.html | 2 +- priv/static/adminfe/static/js/app.d2c3c6b3.js | Bin 181998 -> 0 bytes .../adminfe/static/js/app.d2c3c6b3.js.map | Bin 403968 -> 0 bytes priv/static/adminfe/static/js/app.d898cc2b.js | Bin 0 -> 185128 bytes .../adminfe/static/js/app.d898cc2b.js.map | Bin 0 -> 410154 bytes ...d8f.a85e3222.js => chunk-0d8f.6d50ff86.js} | Bin 33538 -> 33538 bytes ...3222.js.map => chunk-0d8f.6d50ff86.js.map} | Bin 116201 -> 116201 bytes ...36a.142aa42a.js => chunk-136a.c4719e3e.js} | Bin 19553 -> 19553 bytes ...a42a.js.map => chunk-136a.c4719e3e.js.map} | Bin 69090 -> 69090 bytes .../adminfe/static/js/chunk-13e9.79da1569.js | Bin 0 -> 9528 bytes .../static/js/chunk-13e9.79da1569.js.map | Bin 0 -> 40125 bytes .../adminfe/static/js/chunk-2b9c.cf321c74.js | Bin 0 -> 28194 bytes .../static/js/chunk-2b9c.cf321c74.js.map | Bin 0 -> 95810 bytes .../adminfe/static/js/chunk-46cf.3bd3567a.js | Bin 9526 -> 0 bytes .../static/js/chunk-46cf.3bd3567a.js.map | Bin 40123 -> 0 bytes ...6ef.215af110.js => chunk-46ef.671cac7d.js} | Bin 7765 -> 7765 bytes ...f110.js.map => chunk-46ef.671cac7d.js.map} | Bin 26170 -> 26170 bytes .../adminfe/static/js/chunk-4e7d.a40ad735.js | Bin 23331 -> 0 bytes .../static/js/chunk-4e7d.a40ad735.js.map | Bin 80396 -> 0 bytes ...7b3.4704cadf.js => chunk-87b3.3c11ef09.js} | Bin 103161 -> 103449 bytes .../static/js/chunk-87b3.3c11ef09.js.map | Bin 0 -> 358904 bytes .../static/js/chunk-87b3.4704cadf.js.map | Bin 358274 -> 0 bytes .../adminfe/static/js/chunk-88c9.e3583744.js | Bin 0 -> 24234 bytes .../static/js/chunk-88c9.e3583744.js.map | Bin 0 -> 92387 bytes .../adminfe/static/js/chunk-cf57.3e45f57f.js | Bin 0 -> 29728 bytes .../static/js/chunk-cf57.3e45f57f.js.map | Bin 0 -> 89855 bytes .../adminfe/static/js/chunk-cf57.42b96339.js | Bin 29100 -> 0 bytes .../static/js/chunk-cf57.42b96339.js.map | Bin 88026 -> 0 bytes .../adminfe/static/js/chunk-e5cf.501d7902.js | Bin 24234 -> 0 bytes .../static/js/chunk-e5cf.501d7902.js.map | Bin 92386 -> 0 bytes .../adminfe/static/js/runtime.cb26bbd1.js | Bin 0 -> 3969 bytes ...a19e5d1.js.map => runtime.cb26bbd1.js.map} | Bin 16759 -> 16759 bytes .../adminfe/static/js/runtime.fa19e5d1.js | Bin 3969 -> 0 bytes 45 files changed, 1 insertion(+), 1 deletion(-) rename priv/static/adminfe/{app.c836e084.css => app.85534e14.css} (100%) rename priv/static/adminfe/{chunk-0d8f.650c8e81.css => chunk-0d8f.d85f5a29.css} (96%) rename priv/static/adminfe/{chunk-136a.3936457d.css => chunk-136a.f1130f8e.css} (98%) create mode 100644 priv/static/adminfe/chunk-13e9.98eaadba.css create mode 100644 priv/static/adminfe/chunk-2b9c.feb61a2b.css delete mode 100644 priv/static/adminfe/chunk-46cf.a43e9415.css rename priv/static/adminfe/{chunk-46ef.d45db7be.css => chunk-46ef.145de4f9.css} (92%) delete mode 100644 priv/static/adminfe/chunk-4e7d.7aace723.css delete mode 100644 priv/static/adminfe/chunk-87b3.2affd602.css create mode 100644 priv/static/adminfe/chunk-87b3.3c6ede9c.css rename priv/static/adminfe/{chunk-e5cf.cba3ae06.css => chunk-88c9.184084df.css} (92%) rename priv/static/adminfe/{chunk-cf57.4d39576f.css => chunk-cf57.26596375.css} (74%) delete mode 100644 priv/static/adminfe/static/js/app.d2c3c6b3.js delete mode 100644 priv/static/adminfe/static/js/app.d2c3c6b3.js.map create mode 100644 priv/static/adminfe/static/js/app.d898cc2b.js create mode 100644 priv/static/adminfe/static/js/app.d898cc2b.js.map rename priv/static/adminfe/static/js/{chunk-0d8f.a85e3222.js => chunk-0d8f.6d50ff86.js} (99%) rename priv/static/adminfe/static/js/{chunk-0d8f.a85e3222.js.map => chunk-0d8f.6d50ff86.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-136a.142aa42a.js => chunk-136a.c4719e3e.js} (99%) rename priv/static/adminfe/static/js/{chunk-136a.142aa42a.js.map => chunk-136a.c4719e3e.js.map} (99%) create mode 100644 priv/static/adminfe/static/js/chunk-13e9.79da1569.js create mode 100644 priv/static/adminfe/static/js/chunk-13e9.79da1569.js.map create mode 100644 priv/static/adminfe/static/js/chunk-2b9c.cf321c74.js create mode 100644 priv/static/adminfe/static/js/chunk-2b9c.cf321c74.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-46cf.3bd3567a.js delete mode 100644 priv/static/adminfe/static/js/chunk-46cf.3bd3567a.js.map rename priv/static/adminfe/static/js/{chunk-46ef.215af110.js => chunk-46ef.671cac7d.js} (99%) rename priv/static/adminfe/static/js/{chunk-46ef.215af110.js.map => chunk-46ef.671cac7d.js.map} (98%) delete mode 100644 priv/static/adminfe/static/js/chunk-4e7d.a40ad735.js delete mode 100644 priv/static/adminfe/static/js/chunk-4e7d.a40ad735.js.map rename priv/static/adminfe/static/js/{chunk-87b3.4704cadf.js => chunk-87b3.3c11ef09.js} (60%) create mode 100644 priv/static/adminfe/static/js/chunk-87b3.3c11ef09.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-87b3.4704cadf.js.map create mode 100644 priv/static/adminfe/static/js/chunk-88c9.e3583744.js create mode 100644 priv/static/adminfe/static/js/chunk-88c9.e3583744.js.map create mode 100644 priv/static/adminfe/static/js/chunk-cf57.3e45f57f.js create mode 100644 priv/static/adminfe/static/js/chunk-cf57.3e45f57f.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-cf57.42b96339.js delete mode 100644 priv/static/adminfe/static/js/chunk-cf57.42b96339.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-e5cf.501d7902.js delete mode 100644 priv/static/adminfe/static/js/chunk-e5cf.501d7902.js.map create mode 100644 priv/static/adminfe/static/js/runtime.cb26bbd1.js rename priv/static/adminfe/static/js/{runtime.fa19e5d1.js.map => runtime.cb26bbd1.js.map} (93%) delete mode 100644 priv/static/adminfe/static/js/runtime.fa19e5d1.js diff --git a/priv/static/adminfe/app.c836e084.css b/priv/static/adminfe/app.85534e14.css similarity index 100% rename from priv/static/adminfe/app.c836e084.css rename to priv/static/adminfe/app.85534e14.css diff --git a/priv/static/adminfe/chunk-0d8f.650c8e81.css b/priv/static/adminfe/chunk-0d8f.d85f5a29.css similarity index 96% rename from priv/static/adminfe/chunk-0d8f.650c8e81.css rename to priv/static/adminfe/chunk-0d8f.d85f5a29.css index 0b2a3f669749a82391a3b88eec1816aa934fb592..931620872e43d5af521a39992bbca6d4bf41b75b 100644 GIT binary patch delta 15 WcmaDU^-^ksGY6BQ!Dd$u6;=Q)Bm|BC delta 15 WcmaDU^-^ksGY6BA(Pmc;6;=Q)Hw2LY diff --git a/priv/static/adminfe/chunk-136a.3936457d.css b/priv/static/adminfe/chunk-136a.f1130f8e.css similarity index 98% rename from priv/static/adminfe/chunk-136a.3936457d.css rename to priv/static/adminfe/chunk-136a.f1130f8e.css index 2857a9d6ebd3b7f6dacc4c19b3bb02befb8b5087..f492b37d0b450faae11e4d8a0dd6936e67654081 100644 GIT binary patch delta 15 Wcmcblc1dkR1V59Z!R9FbAItzR_yvLh delta 15 Wcmcblc1dkR1V59J(dH=rAItzS3k8J$ diff --git a/priv/static/adminfe/chunk-13e9.98eaadba.css b/priv/static/adminfe/chunk-13e9.98eaadba.css new file mode 100644 index 0000000000000000000000000000000000000000..9f377eee2fb886fd1d5eec1fbaae28e530a20d65 GIT binary patch literal 1071 zcmchVO>ToQ5QVSeO{I1*$fO`eX489AT?}{vZj3>;6Nn=2UX!3G2~CuA(G7@Ue%|+H zqPme}(W}OQZb~Q`<53y1pAtR77E+lmIFFOBeT}wMMsOCVZ9i1;&5W40J>zT!H4KdT#!W2q9HwR)hX#K@tz)rUey`g-UE}$wHkK< zI?C6|L*De+DONjJY3k2o<9BwJ93&NM@x5_gt#%nq6_?TJ??DC`FI=c0$jNs^W(MjA zZBac3xp8E{k*31W3s1W{t|hy0>p1P6L!NpMs8wl%Rb}OesKsy#hqIXvJIgKtE7{?r zCaI7$M(>y_Y(QpcBzvu~hoh8a85Z}pe}3aJ=Pxt=9`$0p)X9r9owvEah9nGrfNaU* UKsNCzbK)JNf8hO1=l|jU0J@ZQ&j0`b literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/chunk-2b9c.feb61a2b.css b/priv/static/adminfe/chunk-2b9c.feb61a2b.css new file mode 100644 index 0000000000000000000000000000000000000000..f54eca1f5eb17ca3d24606775ab6e0b0d3225ecb GIT binary patch literal 5580 zcmd5=X>a2;5dA9xiUKXL6l5pyA${rpD2jlVXq#(E6eudT*YJPu3=f$)d}W)qSS0I^ z!()#3-q2yKsA_7GQkk6!DmT0&E1{Gq;z?8;&Y?FSgfvY>Q>v3<2SqZL3P}|gW!#h* zlQ}P0g1^X?@y%An(^+*$)}mCT=08|Gc^T}-3%X&aJnf+mnC#hV#}zn!NZPTYChMFX{4xAH@r()0 z`KF9hRw^bFVpnuWwF%*h6}2_&dFk%-K^2t*y>(aMrHbfe0EO63A z_A---yeY~AGeY{K;{i^$EZspiU5S{G&9AwcLfyg*PhS0E?ECdSepbeErdVbdt7Fb$ zuAmaq#O6zhDjveYmco`%fYZ;%xT5JU{@&DzuaB1)c1Gh9-wJvlri@<~z%9R@LY9+))ishp0mNe_7l`u9 z45HE!fS&0P5||-#y}#R-RW!?ZxzXGeOoqt?dqp?41TX`^EBxU9(YUvkDzvv0xJ|n9 zu_So|a#+E2mzVU3S5MLlKACohkiDc;jK2&t+CRs=`==`HOVNA|3juhbijk&f(nVBj zkRf1XdrOeh2r4V_%V2!DmrzHypXRa-qFS!%jNZnX1&jxf1-bzMsae`cu8yQ;N`qTS+#sHkW!m2y%h0MrGGP;ZPZ-N89cC+Hf z?rWfFg868R*JzeUcH#6*vLXT67Bc>287*Iy%X3JL=%FlK67pvTN=QD)eEvF_W-rlS z-%Obf#@o|Aow?8H_qFR-4Mph;B3FY%w<4(3XR|KYPKKU1`3)lYryDOtS2??Ch$hAs zH`ca6Xv3HfSGnc^R%vS03kWc)1Yix!W4eZZ<{Q?SHQY$iRCn!$1}g@o!A>g>BHW_P zAj?<)O41UfMuNbe!%F0N(2U$V=QXsBsGdX}D;Adm8cYZ6$O8Ta)k1fQgX67g=*F_` zC9heT=CTxUAU4FT9va$m)(M?1fM#qp&!X9MHamyVwSYtI1~)4D=PUOs8P`U3HiyXe zeY#!y1p6BQ-7pr$HiN;`RxwZu_VJbEzy~8mZ_3wDd!P3GhHM6c?>a2g-r)(|-P`m$ zkYAf{EA?itUO?{eat^g>RnX+X!w5UO;hO(kf-bPqNg3XGl(pQo%|pd4l48vPve=gm zJ#C2&qj`a_dv-V`U&Cw6_N47pA%2jNac`)918CuO&o;bVj-uW@@?6s=~n-A77JjvlsT57a@4afj9bS1!6ap1*n z0U29e>p=Z3811SnMLCS9mrwGg6zLyix(Lt>sV>hfe^7t6-vQ_stFdi~(1i%-3*{dq z-Shz661oE@%##Jw@(q}~Q+~KMP$PJBPcEd%?uzTV^?m=!AfF#V!y=kMqio-o?Gy5K zfupAf`^z3ip5pl)M(`QgL)Wb~z(jvJ_dsG!_x128kl+RG>&?HF!Oqt@J|Y~)+V9E? o9-f1VCMOfEZs9(xJ1z>If>;2-E32zDCQ+WDi?S2D{{ve71|2-~SpWb4 literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/chunk-46cf.a43e9415.css b/priv/static/adminfe/chunk-46cf.a43e9415.css deleted file mode 100644 index aa7160528365a7d1cafb3de0e24fd2322a724be0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1071 zcmchV!EVAZ42EANmr2{fRm!Nw(#zgs+QB4ln~|nf5}*k6-4~!jKnH>yc8L_n_Wyr& zlDbjkFzCjDX-bfd4X7=-PlW+t1=-vCOcq7oGY!ed%O z27<21#Gcm+Vna6F9h~vJMWwVY1@Ds!bI}q(is-x}Nq zSkru=1I(K)KgMbgg`w^=w!7yi$e*h92eHH)! diff --git a/priv/static/adminfe/chunk-46ef.d45db7be.css b/priv/static/adminfe/chunk-46ef.145de4f9.css similarity index 92% rename from priv/static/adminfe/chunk-46ef.d45db7be.css rename to priv/static/adminfe/chunk-46ef.145de4f9.css index d6cc7d1829e50b5720607a863bb78502651301f4..deb5249ace8d4b048b609bbb6fadbbd4bfb85180 100644 GIT binary patch delta 15 Wcmeyz`;T|Sb4DgZgUv4)MOgtiqy@a2;5dA9xiUKXL6l5psIMSE?kD^%65^ZxMkpe}<_8R{0o#7!~MkdWx z?nosSBdZoJExZdQTkG`e=sf3)ccb7yqumG5kLZj0r9I zMx;3tnyHMq75!0TLbzsC;|vF0`a8W-O%*|J{S~+{(Oi`>f6Hu;X5dri!fXC^DN`ok zw;U#dR&08EyHA?<&FAw=!CT4V(CrZfq<;C##SH^`+w{XK_P`?=aVSP`ZRj`t2R=CCbP_mS3 zsDwOo`O>mZ$8d0^uw@kB^z$*UY5taeZyU|mhjR>jqi&WsZ_@^50ZUBp7!%XJ?YQ2` zR{JGmA5VW&tl%_~qC7-Ru3)E#iX!^0qI+V?_<0H3^7}DnC8=1`APH1JJa%%4C_m32 zDjflsnI0j588X+mZ!TsvEeb9+hP$HKIJsc2>Bf}+W*~ZoZ~UE1d+VsecuRrXlrJAg zl2;&y72NcB$((rcB(vayY4-@(DO#ua!$PC`ciOvus?t6c&8N7MfCs7>X&a_|M2!Yn z0!FrX1UXNjva+xYrk4i^^>q7TF6$wxhAeyd9 zcF_<`tSxSAY@^tPu^g}RB5FDt!v(N80ftdfU?cOCu0hwoAQzdP3E3#w);I0O1}nCP z9_6?KLdGpb0a-4kO0}W_C|7~doZ?!RWz#pazg3{O+IlaU&uUn4TW z3|$DYQyh4?IzXmQ*LqOD2}ZZ-LQ#$*8sw9FDn*6|*?s|ZL+SH#BODBS2=j8YwjB}r z5CMIm{DY+19-v=BHvolsa)5gM3e4RrKU^E>2|W6z2hwDJ#pT?FzJF$rPj{eUnatot z<=$D{Q|ft%qsRC|79|8$p;I`iUTN&(qyb&V8 tbFAyYEgl|&i6JK&t$yL&tvfynfr2;y!AEO-t+9y;41JWn*!>^S@;AGmn+*T} diff --git a/priv/static/adminfe/chunk-87b3.2affd602.css b/priv/static/adminfe/chunk-87b3.2affd602.css deleted file mode 100644 index c4fa46d3e1eaf96b247b2e5ef1335a90459fbf80..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9407 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 gw?Wv5WsxEY?PSZ@4R@HHlu2m7y|2#V_ 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 literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/chunk-e5cf.cba3ae06.css b/priv/static/adminfe/chunk-88c9.184084df.css similarity index 92% rename from priv/static/adminfe/chunk-e5cf.cba3ae06.css rename to priv/static/adminfe/chunk-88c9.184084df.css index a74b42d1434c25c64a42591dc785cc9c2a6977d5..f3299f33b6d5a298703ac10d8263b82ebccb2592 100644 GIT binary patch delta 124 zcmaE?^H^uYabZq_MC0TXgG7Va$&PIECg+K?NWw*`a}$fwGxMwr4GJn03=|9|3y4Wg hRuz@vfT@`5F6xHhOchN+aQ=&WV;W?j05Yn!768JYCr$tW delta 124 zcmaE?^H^uYabZqlle8pblSHG~$&PIECg+K?NWw*`a}$fwGxMyBj0!3g3=|9|3y4Wg jRuz@vfT@`5F6xHhOchN+aQ=&WV;W?jU}#WKQCkZDkbfqB diff --git a/priv/static/adminfe/chunk-cf57.4d39576f.css b/priv/static/adminfe/chunk-cf57.26596375.css similarity index 74% rename from priv/static/adminfe/chunk-cf57.4d39576f.css rename to priv/static/adminfe/chunk-cf57.26596375.css index 1190aca2436ae81984753890a2aae62bb70c4432..9f72b88c1f98943d747dc81584ca6b4d3c344985 100644 GIT binary patch delta 37 tcmbO#xkhqBKPP{NVRdd|QF>;cm7zgFg~H|%&NYmaIoP!}$8uXR0s#0H3;h59 delta 36 scmZ1@IaP8)Kj-G1oD&&^G7PJ86N}O_^Q?@F3Mv!~6bv?dawjta0Q3wC_y7O^ diff --git a/priv/static/adminfe/index.html b/priv/static/adminfe/index.html index 717b0f32d..3651c1cf0 100644 --- a/priv/static/adminfe/index.html +++ b/priv/static/adminfe/index.html @@ -1 +1 @@ -Admin FE
\ No newline at end of file +Admin FE
\ No newline at end of file diff --git a/priv/static/adminfe/static/js/app.d2c3c6b3.js b/priv/static/adminfe/static/js/app.d2c3c6b3.js deleted file mode 100644 index c527207ddf4fb46c151e1e7160107d1e89753585..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 181998 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

%88?sk~un(C#3wDekwFwO~&02?+~Z zygY&y-uuk$dqF%V=n97<@R|n(S+&JHExWek@7UaS{RxOd$)0+1m4{Z+bk`0zYimGkq*CG_wNn8C45DqS|R9&pp@ZrQWB zMQo6L7CB!(0f-rl8hqX}JmGkgS)ZVk&$3}mP z{ zhJdHJx+dX{sl2w1-LXIC_Q`a~7CXDdYVe4)2I!K#NUA`-oRoNqn>J0KX@IU$J701Q zMviIXBkHrWwuxqB;RiTs^Qf(3uw?<>*)QD_TmHSI>;_FMJsjM;4(5AH2YTkk)pWWS zYxB~%<_b1#7!K76)6kiEiAP}$<@@EovLB(E*#e~8LQCFE@O~-gG)318DGj7@ouK!C z<$bg>xS0&IILEq02hO8LXp-E^cs4P@NOY1>7GB~FGk@z!^~n=zww=EBCb;(yCx>qz;g{TR#gWbHVWaeP!wp^{NM;F1zLMnF)U?eQfG`Uj&7+;Kju?Y#43 z0ojbFYTH4&g^{poZ#|Rh7b((zOS2o6Fy%HU0kf5OPH`jol9M@CN$D_`I>bR{)Pj5Ou1Fx_7k4~C; zZMeX;x&heV47);ctR3F|WIhM5sT6>m_4^Q_`0Brk}1`nbQ^ zZ3VabWg?P8FaGst^QQ5;3&;;2mVsQ=)Udx~?L$40@7R0~o#H8fgJaMJyJkag>Fij6 z)co-|Dn@7y!WMPAt+*h2IiXpdij)U+l!18B!N2l%em0UX=kV$=ttki_RtEE=J%+0; zEg>;@lvlHA0w&6$l||t)3Q{vgaA3w*^QE z!BEW~Mwu03g52c_j3P`OR+#gHWm%SwvEi28MZ`7Qfw<>${WB)q$}kefD}UH2lD)IS zG}_Z~S;N$&5l~rca;S8}-MOXs>r-9)dl*C1Uidi74>EJqsc;okl&9=dr=~#2;upM< zG;Bh!c|4u(y7yA5BszcrPQMA2P}uYx)^!VfP^E5RJYx4&tq09e0ZGf$8W%DE4$YQ& z1dUj~jK#PKGcM$j(3H(%vWiDEh@*t5oo3Uv4#SFtAbnP98NwXZLeeT`-^QKZ?NfEf zjl@a88oCptoD}OgnG7Iq&oRL*yi3 z=kNaMS3>iI#9mKtfBQ?@ulx`6Mun)=(_3$UYwPv5?eo}woKipf;=S*EefvMZdiV8j zxtFk)v#~3Cnuqs(^ix-iHV{uu?n@Tge(6iM-}+f7;^Wk|e(=ZbpL|J!_s;07Id|Ur zyW4O5F7eCwB-#nGm41_2PH#zVhn! zSHHde)prEc;UKkj;?-cV7M2$1YU^je-TLCox8L~6?YDnIMGqxLZR=MrhXHCW@^NZ+ z|MbnRAN=#ZSAKWzi|R6-q9Ihx^><>hnt$aG*nJhk)wW)K-Mzi@!>?}t=ub?hg{lGI zAN-L?7zm=SA0xLTHhiiH8t7vmYjMD0*${E3a-p|M~DrZojUun_vHYEO^bH zZ~grX4iOzPx1;zqH&g!GMD*JBPyTW1ou3LzfATip7z2)?)#&e+ezEo1_iz8<%ak)H z4o1S)c*wN!rAdHuA%Jb`#UE_F^(Lf#=ik6%Iq62PT>ru=ROUE*%{_Ef!&llbJqy`G zq4&P|y}Pf!?r2_nc#2rtD}K#=v;Cc)Z~X~0a2}x_t`N5Eul@Ay7k1p6r*hH)*|vW6?R($&Bf8RPNZGyTpWph@n|HqV*48@!ClOZ`Sak?0 zyZ6c;?)>1jtsnks>qpN^Lc?Vf=Ii_AV#~JP_~O>DpWph~zrsjpsy!53cIQ9dy7ShL zLqoh%cv-lWp6!)#d{5KcKm2#Pe*2BD-TmS_+poT|{na1;pSNGS`}*(iy!8$E zf{^$3zrOv(_qV?C%H2Qx;O;9gg)*Q2KX2OJ?bZ)p-Tv{{Z@>A2?JvHt_4ogI`;D(M0tR&N`7bbn+wc78 z&Np8j3a$Fz&wl}CxAiYy_&;xd|5)1`$Y(B`NYSRnSUFC={o=RB0<3Po_4BQlzq0kh z@3vn1qT?{zZ~SKa$KSx3xTepye)#70_g+`^@BC!z-t~h{CYJl1fE-p^hgz&4)VKZOKW@GJhC?BB@XLSLe(fvwe)#`;9N&`SK4^+h2a3o^Su=6~+>wFGcS?|K;tk{?FTQ{=GxWtzUiq z?*Di}ll|8JfDG>a=%2UVc!Q~ML#F)J3%|ep&NtlKXhJ}iyMOxLy)V7Q8oTx4ul5Xy zLgs+GUb*f@wDrz6ywLO(6!9t*-23U@39dlkov#7i+i!n=>+5ga`yQz9Y7h(}i9vP1 zaYt%E#M1f@eg4B&w_g4aV0HIj|4IDkAAXe-Gqzv)^1Xk0Z9JR_Xvgb}m7z=9Kl%OE z+b{p`=Rc2(e)WLjm$-Iy^7h`peRu24f8mL%KN>;`!Vq}A{iUs6erNmTuiyFEo0`m* zUuQ^SmS23;k(8$#zEd2cv-NDf@Wng7WwjcMQ;JdHhyL^J?-&)m{0nfxLY3}(8T|eC z2qm`u=_MDG1SN01@b=bgf8dov4F~`2Z~o!l^FIL{L>Wf&bbR}te<&{d+V6x>M)~yb z?(09|+uLt{ck6{0@BZn#V7hy?_1bsE7_D7Lhb+yA6eA%?TR(Uc0BM!}*>5u`AaLh} ze|2~FUU_rt&6l_T;h*>$OCT;NN!_0C;hiu1Ve3n8jeHn~CEa`LyLVsvnS1SoLm>@P zj}u(BzWxv4oexAp5Wn}+?`p=iZy^mH1pCt082av)mdiwS|KfL?=3;I07Cf)n)3wK|CYx`U1HEz$7)$S|5LtNt{ zXNjhkZ+wjp>4e251$oT2k+lW3_3}Tmq}l{2IO*=!zI^9ZAw*DBgv8SE(|_K28&>=GFu3g(#iZ{2 z;w53|JJj$J6L|X%KNcgQ*jIkyxCIcn_np^(v}%Lasr>nWRc&@{6z~gm5UhCER)EA0hVJfBZq9 zs9!)E+yC(`R;ul-WEL%K|GKqEXWK20-5Z<2P6&;bf?$ zTVMa$l%uX)LXARW`>RMMfsp_sx3+khBZVDoD8?vS^~K)bd52IUfzy^^FwselQAkmc zAcw^(FIWbtYG5ICwm5;$U)*}>&8-*zjYSvRj}j!Ln01| z>gz8FBTQCx;1*7O{qNwgu;r-0)|=l1Rh@LB0=BR+zgsVV;qLQabbSd7bSyq->&4%1 ze;Zmiw-adbh5xwsonQJ}7ZDU5I>E}nG6xk`7_LumWJg(&9R+1(=L|r~VAIBT!rVM@ z2phGiobdLQ#n!w3feB({a*qZ(i+}A6EKhH|`^yICm9U*SBS{xSwlt}v$t1i{C)$nF z`n#_XKg!n=?JFaNLm7%EassK9gz$yw$=hAD6BxZ1$qM90Acnu2-Q^ezmK_si2cnC? z>T(jRb0bA^pdpispIiJX@WI5yHf(pn^Q(n-wb^2*Dzdw{=Y;>V6Qhwhc?vtc$yFM_ z{bvX|K|{S>YeCbRFuNS(dZN+AUQ9B%*o)B&K~MMIeTDw2v-?jH6h1Przx&G$zQ;-H z%fxI!&tg(-Gm*jSa&BXQeY??VE#V@f4I6KGIrZVo7o?dVvbe}D!ajJb^X@Ao5%PdB zV+k9~oXW0^VfGbNN;D~Jm2l6OaA@eTIkvb6(YXt$ff6^Ow8|gh0~nu3C4(L370~- zsQVg#WzrUN)-eULwfJCN$42%x$oMUGtK2FS3)s^xqt1pbL2eyAcFn@JT@bGe=~>jC zipbjSNB9zsdhI4{VYz!|7_!EOvJ4@0GQ)cqXeK5Z>S#e{6HEr1E=%O7jbq2@w=!y#4h zp4Vmp%MMOGhJ9wPVXB#w*cfAFu)i~V2&u_8wz$jnn_ZXnjV2gFwh!Sgo&j!~f!Oo@ zyT6p57~iffKw0d{E7in0o))gKtp@5n(dp##QPA&WsmB=U@B%oh*N?4--iRZIM$3-V z>Zbg>PPF8lC;iO2t(wO9*_U5jHTP_W+n}cmB7W^;nU8YiFeM*?Tsarn1zs_^XIIP5 zD29Y}-(l@XxpJc159_#Ke3UEaqg*-oa%j&VX8vcBE9VTUZ#u-h_gFxdS)*9|Bz23# zu}qz34}SMtIq&{*U_w;VM&&%Mu+JzW-dU-rkD7yx3MVC0m)J&icmp3X)_S~Y5GYwl z(Um0n$n1stn*Hj1Y^DlV&Po;M&UvcIzA6=Y1C?C9?Ym%~I;kP@Bp9cTabfCCFix@L zBP*HYg(OJhCJ!RQ4JFU9-ZD=UvgaI=2R6w!kczy8>WKZ?lVGAkX0X`L9;@`Ik;+FY znn#JbVtkZC=R=W0$K>+h!r?UVF@si&s-q^Y@McIJN4_7jMxBKWmm8b>HSc(wu{KCp zfY^tp9`c>nv%#4OIdp_y4_4VD$)V#VLJ|l;p2LD^oUJiPviO%-IVUxmxbf$mM+Yx# zk}kGx7!&XTUeJAX8*F7q>>+`mW=%1xn%T3tO2%A}F(3=qAIEM?d(K(d6T%=jzA63-MekDALGGLNN}{=etH2S>*7 z*OjEbN&ZM@yEp)0UYJlmGMK&ln~rQRkzowq2N@Db+DLYbY$4q8Nz47ef7|RX^k0rNk2OlLfeYqR=!r900eUue#-^Q{d=A(2!mNNV< z_MJ{!1~NRnA_G}h2C^2+4je%1Pg=;{ee(lOsZ9q8LtaVH7Bf3$B{Px(=P{GHiT5@z zn|HgOU?AHiuXZ3~Aw2Ya4S6cgo;}xFAdbAh`0oF7qSlzPY`B2bas29y51D~NhO;xB z6+#im%w{`S%~+1SIc&siMwB?YBoT{JZDGChT0#y>x-U7yvr&b642Vtm@lH(qFmsM> z?73qodmGL^Nghyw;8nB#Zf~oZIMyy^GYzDx*b(yKsAxmXWypBuh1_I4T9x-Uq1hKh zHnewNeNZFX$<|6^&C-az`zpCm)kl1aV2T$8q%yqGP*f7)!l-FY?!-#$e2tNTrXiQM z^S2vbHFmPFxjqLITYHrpsL(I=-yIEXT8a{_dK25ce<%H@cI*r%O(*OD=K)MkP zMfecXX%ptN8fC8j9E+=&$oj2!UrE~6z;Bt>tXZ?aA%-O1n`n_f`|fWn9Y*TiR|%6N zsj=DM1_>M7QkP779DPq61p+YaM)6kSA z7_}4`XXk|0nl3FmT|2nLq4q~cx{r)>%5qBbICi~ci1;X>^}{pL9Y)p{Ur;>IHS!OW zCsNdmoRxxy6PRagH57{FMQ2=#zZL`Dr3RUTyfn#|cp09wh0!;m)-2O5QXD3!V7ePB z(_p1~bv;E2jz({0iMT%WC6?Qe@ERkYc~d0(%n)yQZysiF62$Z&+nx4+gM*n;^^o9) z>YVxRS)Al`x10G+5emq9W{L|T%g)8@DCrZZX*c`b2D@#;7Q6vV3Lg#I@OYw~U4l}A zY>ND_qZRL1r>{gP!OLuZ!+1W!2v)qg4rvTDE`*7pG}t^FH{zXQO5XjSwyX+zV&*J@ z|LdPxtRT{eSrQf+3ylpHH)INSa9PmENA9ebLvCnuV3$@X7NtI(8`8 zEy^rEmI8aN*(PYKVa)*nGG9CGKux-(&xwUJh&O^u6~NAYXTdfcNe9_qRP}5y%6_0i zTAQ?9+CrLPCFUB350++aIFb(AH^BN$9(jVkv>{D*78E9>wOVTzbq!Q+R8cl{0UHMk zI-^%&Y_{z?rZn5yZbW6xq_C3H9q*TPu*0CBBWer*faTJ zDkf$dZhN<^+3MAMYGHzp+uY~a7BmS7G+7(H89WIWJkO0C^q-6^&91d|}H3JAGy7+DB|NzgrHvPG_vq5XTa3!dcZz`m0DC0*~< z^sUV1K}8{Ru-+7BD3N=tSzpXHvZMl>O1Fvi-M~tho__ARv=UFO5j@U)zrK8H`pH4I zac~ps;#%$E?&k=0vP)BgOqx|`nY7rVs;Oy`k8x-VTk^nNXV&$2T9{bu|g zU2XjR)%gALr1#h3_b~MF@6X5Yv5JnrKNi0yMaua5%kleXC%vDG-?Ng9e}9CDj?NS9 z=DK;#MjSqe@%mq%y}(1xVa#@>`u-5cLGeO|haAE1`2r=JXsr4ymGx_`K#hXaPL$m;`*_0|Dwh$Nn99IzmIPLv`6!GUy^ zMViHD3M)ATPtJTLdRNLV@*3c)E)&VQsm?Tzus#&xJ~S438|x5?>l7kHR!fC1SFEcz zbhDI>zu-u_^{Hu$@=fZSp5AL_*93}a(w6rsz8nK$c{e~rHGaTgXedYI0hep!vQKOf zK%ksvA3OjyhXAm$7XVgb0Id8u03b;RZd3lU@-bXqX5sj70pjWiQ(D*y6bmsZ7Ip*0 zpASkgwh`b>pwq&@K^Sl>?`qP zU)g!GKdh@T)`o%co_OZRR{))P)u@xZ4Wj|psms_9ka7D%>wSaO^#fR`k2!E<8kt^% z&+~hMY(56r{O%yj^fTS4-uFl+>GZ*a2m4v`9V|WsnM#2s z%B{5rM7x~+y*ETV2&qVFmu;`7k!kenePX0I8IlZO^;`gUF&46R70XHwK`gr*fE|J4 z%wC{A6NCE9UZ9R@eUE_V3|Gm{4_Qjiqz||(J(rOXuwY_>iv{N$Y1RV|UGuVmd=dyB zrDT0l>zFjFf$6{U>_5s-p7cxGI{Jyu%>vLthvBj_eRF__KbY#6{GJ)aI?$X$i3oHj z_CkmgF+!Z!9U)vlVE~!MOgpolS<0+t)-ubXs}GDMj_x7*LGEB9>kOiS32@xhlGub^ z9x8F3mCMHzp46fvWx|tuzMd~ViSJqCic^zHfgZb59_5BYC2eG7NtC zPTh1p&`DQ{HVfIL>RrovT3SU6#q7fL)b#YN`psJy0y>{P-r383*29Vsn}JG)o1bCQhs{7I8cz*)m^1@n z)`K@p-PE3meBx&G$J{0o_cLtnVByOsr2nuE7|W${seGZP|F(UhMCSF6eY}5tZK2yv zU0Z8+`UlgN>iWQet5>gPuNJf2-pYYOE|)vd-&{$Pz0_(tI8;-rwRAAiiBEi-LuXf0 zJUf-om$Ug&HJhsx8u?<1f7XLkDLbow<;8rqT+UXCsX}%(S50wOsPHmZndM2PR^ubS zr@!vUeO$;^D|uIHF2tnPoT+4s^tqTV)oL_Y zs}zrvX`;Z}Vj)Ejss+s-Fw555RIb{XZk(#+v!!AwmCwy)bEPVCSuK^A;9@qHE6!v~ zd5xl4t*M{IGFRo9Y^^|fYc-#p&4+7dt;&dOJlP`MU_J_EO%~l_>SwDn{`3F8y0h(# zB1hu*`}-8L%}N(UrfIj`wmV!F-3IQ4yX3`b*|`UVP#7jmL||DG=D1|}?)UqbdnN;6 zSFDbWbc)jB_CvW`Rjw+R-QB6Btnq;|-)Lk7KIsW)c4<~Os_bB|3-lMV1VA09cG@Bc z{Q_j2;VNN(rd^<#kA=zxH;b#*q;QqY-iys}xS(^1Y*^#EE*#Umx8^j=eE~^3F;r;h zNr-SR&$vqgEC2x(Lc(ep37Aa*0z|U{o%F&7V0Y)$==L{% zxLwr8H^0YQXRD#c#BUvI3t{-`>+bRU?H|5>^X{4yVQ9jp9_04g)u2$=)PgO7h6KUZ zHnqzhY4bDBHH}l<<$ykNJ#q@CyCIDv^nJJIU`3BwL&}=ekNO?}nl@3bv*r(T#U?$; zQl}Yp8Fopg0%FlnBR3RD>({M2X%Y4`ZaX2dK(G36GIl*Gbwpn%$3;X7PCGICgyPg9 z^XRUWmH|~4-9!NTJS~HnwCK+~6Ert!o4RE*EmKX>&Lra}P}cLq6wnwEGz7^@Gipw0 z0x>I59g+Ei&QAdy7|j->Xs|rv31E-ZOC8A+NXVg_>&F2wZsZ5)MrK1lVg@DjL_-C? zQ2nlnD|Asu4WnZ~F&HU&lpPqRBw930He!Sp`*TSRw}el@`$7-&KFynUNk3#R#z0Kx zFUKeglq*WQ#n!2FCRF(`vo(ec5qvw}O#?q6V+9=OLk-_Db)E5isB#EpA^_~VHhWIm z7{$dzH5J@oAeg!$Ylc`DPN~BX#cHe?bkwYE9es-vb)Q>HE<|^18Dfdvr%Hf=zVTKC zT2fi27+IyfNY5@di&S6RR)lNr@wJKzEB;elu_9S;uxY$OX&Op$NHFvSA- zHx$AFr6!cP+?fuSr`73pAHrUS5kAqA4v`Nu8o7z<`3@6ph?Ln6YF!HqI@i$3EZc z{P^Nu`I@Yb4j65k9o=8M-7zFK4G)Unc|VG&ZVyA9Otf_^(-RvGbTxqks`WKYLzV7;f*R5;8 zf?V!n=?Pr$zfu1I1?82$`d{>DD>^R4jj{neRKg2K#bZSVyd!?7=C#90H8|;5ISq^i zH3kg*RSb({U9MiR4ASCg=;Zeu;NWt7+u6>;uu1uwjjS=u<+#$emWLGHP zns(N;j9EGWS@&_@Gfh|)j;MzIVKnClXaNTT@;%Y| zS&I_6S66-?mLBnE4hhpesG8O}Sc5v{FP8 zEW|fc=fltkkcF*AoWM&%vRAPRmLB+VsDpq& zAbv=~s-mS01S@QOz%XG&8J}Lh0sF`?HG_fm#mjlL} z){+>_htq+IYkOQKOpssSK^fLecuC4-8p&zGq?NnUqL9FUzCfO)>CrU_q#gs>6iD5F zS|B|}-Zx(&E;K;8j0+hO8v==R1r`Dk-PXq$>ju{5B5@-2g5xA^nBeJ7losp8VuXBM zqBp1!%f@dmO@=#z#7Ia;sxxwv2Iw5);V)c4O$Q*j>MaKs`69|t5Ssq}pZFvmpI18gxa=tK z*Of-!@+q+!9v8NYC1XM?oUhZ}U?E6^Yz@Hbxuf=(*M>m>x~keVu2Tf_l$YIpd-&$> z$ItY|KbM==@AOQx4#-^@&Ijs#ed=y`@5kdS;nsir<)05N+^XG|^3JfWi`AZFoCk|@ z@R$%MyXUVtxqe!+QO!d+r}G*?|KQZyun#bQzp>LaY)#!cKH4H?%f~>DCo0{1&zW;RC$c+VZEOv(`P2HK z96RNJ-vDsuY+{9Wd4C!$#^1FO#@H zjOM9ZG`wjP_4I@k%N3(Z4i?z_dZh&$Gb&povGjs^T~ez{?vQ*O0cl?T z=m@2WW+Yxj*h{fTT}zo1CZXGy;sjMrTRh^}N}=QQDz0TwY3is7a6fLZY&pel>QubZ zElbm(XoiP_pUlD(r+byDgqAifi*kB2E1@2X8!m{3H_eD)U=-w_KfSYnr016L! z-7|b0D@$*|BiFF7>}1BYD?$6^7?UoBF*wyV@hTXX?)DV%f^@cW>Djs=o~eeTW}L+9Tc9IrK|&4?I@?;J)k&*afQM>7 zbP1)`Py-Q0%@y00pVSXUz}M06X|T^%bL2|cS0;>lNuH6GWpaDCbF7Mx0b&t`Q^zlRaDE2jLp{Z_t%1d|i5EKy&LJxr0+pos z6u?N@?(6nHK;&15%-)OTHHnOUm1t9BEaPWI=C|5WE@*5B4FMv}@w718gQ9Qqq?wky z^8@MJ61>OGQDns>Q`?V}ogbjTQ8Gx9L1>qPQ1YhD=d)Z@cC!?qfJ7QU_?se7w0 z85=~aybg4%PA^n9?1aGjsDhsLsMiSam=Ih01(z#Ld8UfGYeeo=RJEf9qw3a&MLX*v z(0b1ShAkdLd3o>PHJ&u7zfPXrs_g(;iX$#9NhV?LH)XT-S^A;1-8#h z?h~H8JF1_|e)?(cM)G`y&L!v-tEcso=UW$s>W9Z}U+fO*K~5kS$D)%L}z;XB@A{9cx0?O!E5&>ztG0r>E89$B%E``Q7c? gH-=At9y`TP63M@lIn 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 z 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%0AZLY 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

S-yc7*K1a$^Q&#uC!N!Q(vM|DOh}Cph-sba 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 At^fc4 diff --git a/priv/static/adminfe/static/js/app.d898cc2b.js b/priv/static/adminfe/static/js/app.d898cc2b.js new file mode 100644 index 0000000000000000000000000000000000000000..9d60db06b8d453dac55ccddd3d021d42175b15df GIT binary patch literal 185128 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

jD6s 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ℜM&joT@kCQKrZGf-gswXcJZaSu{nA0(9RRv$U_3 zTVr7aS~Z)tXJFVgFGEC@E2iHd?9ZgR%r-lMN6=qK*bN9{sv;QnTFBh|L?fIEhb0*% zXhI(IwjGrcjfjn9{%2i32b|hgY#VITEJJc9>#e`hlKY%DJNG&zJ36{?mlm2{dz$CA z=s8e+@!>b=RC4^WInqf02j6lES28jar2XK)IaRDkOPS91W~a@A$}j3)HN7u?F}pGH zO3s>X@1wcJU#3Jf8#B7Bg}XcaWt1wGtT-4snST8+jUzN9Ka%jfjj;aiTeG7u6?f`{ zpmuzM28}!501=#K>v@^#S_uhYeDLmE?(kADTV5{)sbUh-=<$tP)tXkcX|em2_yOaI z4hRtvdCSD)&3jwp@x#e(%kM@r{=QAqI)sbQT&h@D;6F;O9|`3|2?*aPgZui7W#m zoF7^ZBwqVT!I+I*5vlx}B1f4!`X`zC3aDe)$K4nZ7zY*f!t-3C)r`l1;5}dd~?lTK6;8rext1$4E@qRQ$bi6#@0 zbORIp*ghHZiLsQXvJpPHTi)jY6jQ!!E}5epk5wPV53HW^FRsEDcl2bR+b3~ew?=jnFOpMBJjn23 zjM`+p-ONYOOXThrKFTieII+v#xYwzsx*ycvY0q5%zqDCmnc0`1&ccLn5-UX++FwE25|JSTqy=bU(Fv$MY2jE$xd)K`V0{^RagbS zB%H>9l2!EJimsnxrDI4>DtVc2TjBlRw)#6&8V0ZZVk;LlnLILJXu^(FL@o2=+mmnA zT9{!UI)~_w`hKr{#b5NyED07-N2h!?zbQgifryugDEBZ-A$O+qezdBKCy(&6C3BXi zF1ld&F64u=a#cpD0lFB*x6-)ih9i7>euNK~euNL*tG4xRY-sL+edw=kcPv3vDU~2g z`m(Qj+fWC1Jq`3If%nAVjNdU+L+&}Q~w=Sau zS4B4SZ;XG=CCO{SaUgxuh<5Ba_UMQ0*d3d{h2Pk>L~)tFkhIv!-f=NNjcl=3SZb^hnM6ZRi5&!Tx?#Sn#fP{+CxK7F z?~trt3mAJfbauIljMfPP$;N8g(IRHhQ;8qS#?!=XSDNqGFd?{BIT;<2NuEDyOQ8;MpHo)(% z6NA@ifVq>R9mvt)>eO6-Znmls_+NvrX9p8_?xxF$3#cIA9L5A>{v*sHW`c`F2y_+{ z;)jygwi3SaTP1SvY#OSF8o*3jTo_=_4Pq{uY-N)d zR+{I>_cLsS^tAbT0XYhuwL4DK#Tyv#RNS>#Svx>_QM|lI`X7zg)?8nN(%H zV=uTnv>D}o9wUdB$CWJ#8J|w{ZTB*b0fXo2|}m4}?&O5C*= zgjGU@pq@8{%x6MU1?{D1#t*{#9Z`dQ_+b>R+Y&qan@rdsZ`Le6=$=K9giZEuPTdap zA&cVI0KesIUI|;qi^Lrc_^S+1 z#6JVypi5+ih;&SPRDy0T^H)G|2~rAoXZ)2#`pDn89H~2%XW(^EzX0;=4Iqz^t9>w-Q?=6>L#tc z(1xN+)!~qib1=)XqMMmz;b88EyRsexX4xt8nh75Pa}T5UHCnDXbUDt*`aTYBI9HA2 zx4a4F=VdTY1DGGe1n>EqgIVGDnxJm}HJG2VRA{7TPN-yW9eu?hi*D;&j?dw}7J3$| z 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 adeO6AmhXr=_a5B&@$OxG9j!A7rvC$uV~I8Z literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/chunk-88c9.e3583744.js.map b/priv/static/adminfe/static/js/chunk-88c9.e3583744.js.map new file mode 100644 index 0000000000000000000000000000000000000000..20e503d0c633b4e7eda94948db6a3f78d548fc31 GIT binary patch literal 92387 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 literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/chunk-cf57.3e45f57f.js b/priv/static/adminfe/static/js/chunk-cf57.3e45f57f.js new file mode 100644 index 0000000000000000000000000000000000000000..2b4fd918f45d545b6ff3ab7f795810df2c9a5ae2 GIT binary patch literal 29728 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

sk|UiM-x(gS+*xuxr`@1!XWV6<-bJY7n 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 P2lwv%f?|gBrt|*+`MzuY diff --git a/priv/static/adminfe/static/js/chunk-cf57.42b96339.js.map b/priv/static/adminfe/static/js/chunk-cf57.42b96339.js.map deleted file mode 100644 index 7471835b947c8cf23abd8484aa4f9b9e239cb3f7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 88026 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 R+tCj{ZQa3V&^nV~`ajqih{^x} diff --git a/priv/static/adminfe/static/js/chunk-e5cf.501d7902.js.map b/priv/static/adminfe/static/js/chunk-e5cf.501d7902.js.map deleted file mode 100644 index 60676bfe719200acd489a5411c786e4e973f27b5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 92386 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 Oyz2LpYg^Z@efj?>pmQSt diff --git a/priv/static/adminfe/static/js/runtime.cb26bbd1.js b/priv/static/adminfe/static/js/runtime.cb26bbd1.js new file mode 100644 index 0000000000000000000000000000000000000000..7180cc6e341ee1fa248750bafa195086b5fd0b4a GIT binary patch literal 3969 zcmai1TW{kw7JlDf;cx*%gc(NiO}>mUm=5M)2ko}l>AtMjC}`?%q#1>DDXM8|`@i?l zx+YDgKoFDS?K`*coO7cpEnRMO0QA(3M>^0rGdB25*pX$DnMCixJrnp7?OBdL(>=?Q zkQK>pzs=qlUV#4WvTZh*P??xrwrAB&?#JVTAFDD8`~+{H*RM&q^_SaGu1A9Yc=!U+ z@uM})xW^;#Oq4%9=%1`Pf_0BvilPVvvvfQTU}yJ05LpmO(2z6Vu&(ngJS*~0_ z1G=@~Dyv88ihzsbv3Lv=%g^T>;=nDJPF|dJ;sVo%Prp&^of<{zR$ZcYde3Zf6Ec}Z zTlvPd`=sCJ6($mg(r&|>B&l`+br9h8h+Zs?6fciOE+Npjw=ALz5)&9+;t5m^ZmA)| z<*NAPbiqeXw$d)YhfR24)sva>%I}0Q-e|cBECQs>MSjfT_oK0{-1<9R;y)#)e4|;h zxrtb_xH(-et-`<`x@z6EXPIOD*r4`!;Wf~GyBH2hi^RKyp zKN^ouL}r!#dK;!IMK%#@rqOIEhSP-_PUlM)Zk{W37;Ij}huLttlIy`Vn!#{7J*?$m zRjNfin6BcbU%gzR4kSo4JdDsg@ADFZPG-$q?s<5}8$uhmAP z`67k6>hG!bN76K0E1358#A*#s@HTjA1(TUvLJFw97Eh{{(--$&EkL5xJY3CF)$OlM zLDnk}B0Y%iduieANej zkYUKHJ@x0`IcM`jrj^e=4hHV^X zS-ORygjVR6na9$TL%+jocbeJ#g>DBIB({D?VfOBXcxInc+WhEH0fNg+*b-bqDyME& zgbD6*kEvV|O+#k5mgBMDxMSen+GvT);TI_$KqK@9!(7m~iH|X~y>Pj_`%>PK$!i=5 zk$_09=PgI7^+C)ReqM9rY8j3(@0T z)f@@4#cCGM`+FLWjMu4%7R&ygh9jk#O(Pli`x}m&!5ouCj9goDBvP+BM6$=-aO6@# zyjsU-$4p_i7q((sE*jGD3~lj*q>{k3G$bn+?Oes zF1tuZhQJ{8oQE-ueDIcFaxF1v$)^55k)vOYcm(!BxPK$mH^>08j(An`k7cS0eaRda zxq+gWYe@ZO@^lnpbyTx0dVVWhrwvT}!O7#^kS16NtEp)tE=VHL6y`p*S~lwxrg~cO z1oDJCL{|+qwFdWfu*T~QzfS=FOJpg-)7~mry80@_a1-DVQ%zG^r=W5TZ9(nV*a^&ewCPO(}5n>$OPlD|Kt5j{UL97mo(sN<_t8tI`!9~!qAmpf$kVV?TM;z>f);a2v*f&v^gu^# zY5q*wx81e3o*E+r|Ce8Bp95mDR3UOy<*VXDeZC^R+K` zQ{g!YL5cWMOjE}sXrveqGLi}j&N%7gb#|3f2Y`X-_?{KmWz_jaog?LnO2+DZR*xck zCbc)fMLRgK?t-4VKr3)^yypU!J)_8Y`wq?d@#EI%ItqqAI;D1Q)9AInPd<~>t9H>; zx>pGRlmvzGwIC@l?vVu@e~L~;phxXuSr*TO3MfVt^I>%EI;zgKLSwu&BQ~f6v;s_J iM{H231lNK}-m{1K<rkL|_{>xb{2CK#3^>TRNl?~Y>vsd$*>l3Ndj|bPxhttt!KxVvZ-gjY} zV5z*|oX5v}M404=?TdS3N;p#(7^CrGJR=gQwCF;78Rx$9sIs#vQTgBEW&{Lg6>aMqrE4+~`jVphu0HXE4MY8u- g#R;Q8w<2x)+v)lJ_hFc8g+1-2ai`d7K8xr5F9H^VjsO4v diff --git a/priv/static/adminfe/static/js/runtime.fa19e5d1.js b/priv/static/adminfe/static/js/runtime.fa19e5d1.js deleted file mode 100644 index b905e42e19aa329c594697f783983b87151c7707..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3969 zcmai1ZExc?68?U_!r=ji2s4ai$(CPMSZo*j;TG++xb1zpoKetxiL|SbE=4s>ZU6TU z^=faEEs$7z=gc#lXJ&3xt+^|V4kSMI?THRF^o$LDV|HQ*Co0qT;f@&iL_1QzXTBqO z7Lqc%-)-|Zik4D8xO|(>W|(K{KHri0p6{m9k{;?R4}1qsz|CkDZvEkQQs{}nKkh$E z?&!f9XWa8a(wtR4KIxyVIY{fCDQ9IF7-I2s8pwOQ%MASB<^(aF3J`T$_SfT*R(YXZ zfCId>($!W^SR(;theP=sFjAaO_aFx>IS=|mXHZuX4f5$XtUptOO2e$n*uCBno85$j zXVI3was4{(*LlE9=0MwRc#~!Iy#WjYSRUcCg%+dLp)9xz^zAK)Fp-%G6s=$ffCEcf zk>$AoK0BV_lB2D)D<0)0Jd^sW$2|Ch63QE`P=N&jad(h!Q~CSBSXXWRnJ)7~*|FGY zQf_V{(oJrT=W`F3^qU&gUB8z&GPV_N_uPnjp+TnQ(*$1UV^PUq&{-2Vz7m@SN5AiMl6ZzFnjKO4#~<&|2E~y8 z8Dxma#}DrPW&eNqzrMTdUtxomi;%D6Dni#Fp?DoHlLe?Tj5lbZ1zV_f8js^GY)B*v zu}}AMSl{A@gb3G4&d2dot>u-xojfhTXwH-p%WyuNBh+&K()!i{i0@gM1`%PX6xh8e<4*bZMB#G)x*Ttu=O}7NEG68ufIlsbdy^9$U1s{B+`ANbjQ` z{MG<{4~G@{Ve@|** z5t9*LCp|~1^`0dQ-)}i`wTy&{LetYsQvdzH5$5(lFsn09SS69Y-$tA_^C5 zaij-2n;%Lbp8BUeeN_7NZD?l^J@ z47*yVVB|q=AMub(xa6F(o+Ce3=nUy0(B2sB@X-v;>07PG)@4a}DVhDbeKSGv@+0?Y z3WUqSlYt??Nj>L*PXiykMG#yoa9Xr!2QYB-s~!)6z2NTOaPwftiV8(g0g z2T5+g@VRlR-%KGUA!J7_+rrE5l^f8&M;08t+#A$63nDEwZOj=+B$~^_`&Q3p17K{V z6;B{n++l`nu*)^5&x17_r|4rQ;g$lwigL-k@mHJHr98KY=1U|Et7?agh_c2p? znoayHGELC|FRs%`PN)8ljyH{)yy0Ee3D+`bpy8=we;fb?Px%$m9YKH|t4oKzM!Z#C zg4Xj!psK-e1X<#t{_~c=%?W=ijYAun!Np?)`c?H^qG?{b9G>#%xwuyWX0B3lmV2M+kf2M{c}>8+H&~=WCP$D Y|NYPRSy#nT#iF%bh=_i!=p#G)3G0m)X8-^I From f6dc33615bb6a27cd0a963bb8a610bb30bd6d619 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 31 Mar 2020 14:29:43 -0500 Subject: [PATCH 148/188] Add imagemagick to Docker image to fix broken mogrify plugin --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 4f7f12716..b21f86fcd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,7 @@ ARG DATA=/var/lib/pleroma RUN echo "http://nl.alpinelinux.org/alpine/latest-stable/community" >> /etc/apk/repositories &&\ apk update &&\ - apk add ncurses postgresql-client &&\ + apk add imagemagick ncurses postgresql-client &&\ adduser --system --shell /bin/false --home ${HOME} pleroma &&\ mkdir -p ${DATA}/uploads &&\ mkdir -p ${DATA}/static &&\ From 2553400a662de7170dd56ee0950a6c1bb1513e45 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 29 Mar 2020 22:01:49 +0200 Subject: [PATCH 149/188] Initial failing test statement against funkwhale channels --- .../mastodon_api/views/account_view_test.exs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 0d1c3ecb3..8d00e3c21 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -5,13 +5,19 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do use Pleroma.DataCase - import Pleroma.Factory - alias Pleroma.User alias Pleroma.UserRelationship alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView + import Pleroma.Factory + import Tesla.Mock + + setup do + mock(fn env -> apply(HttpRequestMock, :request, [env]) end) + :ok + end + test "Represent a user account" do source_data = %{ "tag" => [ @@ -164,6 +170,17 @@ test "Represent a Service(bot) account" do assert expected == AccountView.render("show.json", %{user: user}) end + test "Represent a Funkwhale channel" do + {:ok, user} = + User.get_or_fetch_by_ap_id( + "https://channels.tests.funkwhale.audio/federation/actors/compositions" + ) + + assert represented = AccountView.render("show.json", %{user: user}) + assert represented.acct == "compositions@channels.tests.funkwhale.audio" + assert represented.url == "https://channels.tests.funkwhale.audio/channels/compositions" + end + test "Represent a deactivated user for an admin" do admin = insert(:user, is_admin: true) deactivated_user = insert(:user, deactivated: true) From b30fb1f3bbf8fb8e49cc5276225dc09771c79477 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 29 Mar 2020 22:30:50 +0200 Subject: [PATCH 150/188] User: Fix use of source_data in profile_url/1 --- lib/pleroma/user.ex | 5 +++-- test/web/mastodon_api/views/account_view_test.exs | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index d9aa54057..ca0bfca11 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -305,7 +305,8 @@ def banner_url(user, options \\ []) do end end - def profile_url(%User{source_data: %{"url" => url}}), do: url + def profile_url(%User{uri: url}) when url != nil, do: url + def profile_url(%User{source_data: %{"url" => url}}) when is_binary(url), do: url def profile_url(%User{ap_id: ap_id}), do: ap_id def profile_url(_), do: nil @@ -314,7 +315,7 @@ def ap_id(%User{nickname: nickname}), do: "#{Web.base_url()}/users/#{nickname}" def ap_followers(%User{follower_address: fa}) when is_binary(fa), do: fa def ap_followers(%User{} = user), do: "#{ap_id(user)}/followers" - @spec ap_following(User.t()) :: Sring.t() + @spec ap_following(User.t()) :: String.t() def ap_following(%User{following_address: fa}) when is_binary(fa), do: fa def ap_following(%User{} = user), do: "#{ap_id(user)}/following" diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 8d00e3c21..ef3f3eff1 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -178,7 +178,9 @@ test "Represent a Funkwhale channel" do assert represented = AccountView.render("show.json", %{user: user}) assert represented.acct == "compositions@channels.tests.funkwhale.audio" - assert represented.url == "https://channels.tests.funkwhale.audio/channels/compositions" + # assert represented.url == "https://channels.tests.funkwhale.audio/channels/compositions" + assert represented.url == + "https://channels.tests.funkwhale.audio/federation/actors/compositions" end test "Represent a deactivated user for an admin" do From 185520d1b4d3fdf8ecde7814faec92bbb531ce59 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 30 Mar 2020 02:01:09 +0200 Subject: [PATCH 151/188] Provide known-good user.uri, remove User.profile_url/1 --- lib/pleroma/user.ex | 5 ----- lib/pleroma/web/activity_pub/activity_pub.ex | 13 +++++++++++++ lib/pleroma/web/mastodon_api/views/account_view.ex | 4 ++-- lib/pleroma/web/metadata/opengraph.ex | 2 +- .../static_fe/static_fe/_user_card.html.eex | 2 +- .../templates/static_fe/static_fe/profile.html.eex | 2 +- test/web/mastodon_api/views/account_view_test.exs | 4 +--- 7 files changed, 19 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index ca0bfca11..ff828aa17 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -305,11 +305,6 @@ def banner_url(user, options \\ []) do end end - def profile_url(%User{uri: url}) when url != nil, do: url - def profile_url(%User{source_data: %{"url" => url}}) when is_binary(url), do: url - def profile_url(%User{ap_id: ap_id}), do: ap_id - def profile_url(_), do: nil - def ap_id(%User{nickname: nickname}), do: "#{Web.base_url()}/users/#{nickname}" def ap_followers(%User{follower_address: fa}) when is_binary(fa), do: fa diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 9c0f5d771..53b6ad654 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1379,6 +1379,18 @@ def upload(file, opts \\ []) do end end + @spec get_actor_url(any()) :: binary() | nil + defp get_actor_url(url) when is_binary(url), do: url + defp get_actor_url(%{"href" => href}) when is_binary(href), do: href + + defp get_actor_url(url) when is_list(url) do + url + |> List.first() + |> get_actor_url() + end + + defp get_actor_url(_url), do: nil + defp object_to_user_data(data) do avatar = data["icon"]["url"] && @@ -1408,6 +1420,7 @@ defp object_to_user_data(data) do user_data = %{ ap_id: data["id"], + uri: get_actor_url(data["url"]), ap_enabled: true, source_data: data, banner: banner, diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 0efcabc01..c482bba64 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -43,7 +43,7 @@ def render("mention.json", %{user: user}) do id: to_string(user.id), acct: user.nickname, username: username_from_nickname(user.nickname), - url: User.profile_url(user) + url: user.uri || user.ap_id } end @@ -207,7 +207,7 @@ defp do_render("show.json", %{user: user} = opts) do following_count: following_count, statuses_count: user.note_count, note: user.bio || "", - url: User.profile_url(user), + url: user.uri || user.ap_id, avatar: image, avatar_static: image, header: header, diff --git a/lib/pleroma/web/metadata/opengraph.ex b/lib/pleroma/web/metadata/opengraph.ex index 21446ac77..68c871e71 100644 --- a/lib/pleroma/web/metadata/opengraph.ex +++ b/lib/pleroma/web/metadata/opengraph.ex @@ -68,7 +68,7 @@ def build_tags(%{user: user}) do property: "og:title", content: Utils.user_name_string(user) ], []}, - {:meta, [property: "og:url", content: User.profile_url(user)], []}, + {:meta, [property: "og:url", content: user.uri || user.ap_id], []}, {:meta, [property: "og:description", content: truncated_bio], []}, {:meta, [property: "og:type", content: "website"], []}, {:meta, [property: "og:image", content: Utils.attachment_url(User.avatar_url(user))], []}, diff --git a/lib/pleroma/web/templates/static_fe/static_fe/_user_card.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/_user_card.html.eex index c7789f9ac..2a7582d45 100644 --- a/lib/pleroma/web/templates/static_fe/static_fe/_user_card.html.eex +++ b/lib/pleroma/web/templates/static_fe/static_fe/_user_card.html.eex @@ -1,5 +1,5 @@

- +
diff --git a/lib/pleroma/web/templates/static_fe/static_fe/profile.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/profile.html.eex index 94063c92d..e7d2aecad 100644 --- a/lib/pleroma/web/templates/static_fe/static_fe/profile.html.eex +++ b/lib/pleroma/web/templates/static_fe/static_fe/profile.html.eex @@ -8,7 +8,7 @@ <%= raw Formatter.emojify(@user.name, emoji_for_user(@user)) %> | - <%= link "@#{@user.nickname}@#{Endpoint.host()}", to: User.profile_url(@user) %> + <%= link "@#{@user.nickname}@#{Endpoint.host()}", to: (@user.uri || @user.ap_id) %>

<%= raw @user.bio %>

diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index ef3f3eff1..8d00e3c21 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -178,9 +178,7 @@ test "Represent a Funkwhale channel" do assert represented = AccountView.render("show.json", %{user: user}) assert represented.acct == "compositions@channels.tests.funkwhale.audio" - # assert represented.url == "https://channels.tests.funkwhale.audio/channels/compositions" - assert represented.url == - "https://channels.tests.funkwhale.audio/federation/actors/compositions" + assert represented.url == "https://channels.tests.funkwhale.audio/channels/compositions" end test "Represent a deactivated user for an admin" do From d3cd3b96bff4c8ba205d4699eb8cf9d1b6fd5a7d Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 31 Mar 2020 17:28:41 -0500 Subject: [PATCH 152/188] Remove problematic --cache-from argument --- .gitlab-ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1b7c03ebb..e4bd8d282 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -288,7 +288,7 @@ docker: - export CI_VCS_REF=$CI_COMMIT_SHORT_SHA allow_failure: true script: - - docker build --cache-from $IMAGE_TAG_SLUG --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP -t $IMAGE_TAG -t $IMAGE_TAG_SLUG -t $IMAGE_TAG_LATEST . + - docker build --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP -t $IMAGE_TAG -t $IMAGE_TAG_SLUG -t $IMAGE_TAG_LATEST . - docker push $IMAGE_TAG - docker push $IMAGE_TAG_SLUG - docker push $IMAGE_TAG_LATEST @@ -306,7 +306,7 @@ docker-stable: before_script: *before-docker allow_failure: true script: - - docker build --cache-from $IMAGE_TAG_SLUG --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP -t $IMAGE_TAG -t $IMAGE_TAG_SLUG -t $IMAGE_TAG_LATEST_STABLE . + - docker build --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP -t $IMAGE_TAG -t $IMAGE_TAG_SLUG -t $IMAGE_TAG_LATEST_STABLE . - docker push $IMAGE_TAG - docker push $IMAGE_TAG_SLUG - docker push $IMAGE_TAG_LATEST_STABLE @@ -324,7 +324,7 @@ docker-release: before_script: *before-docker allow_failure: true script: - - docker build --cache-from $IMAGE_TAG_SLUG --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP -t $IMAGE_TAG -t $IMAGE_TAG_SLUG . + - docker build --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP -t $IMAGE_TAG -t $IMAGE_TAG_SLUG . - docker push $IMAGE_TAG - docker push $IMAGE_TAG_SLUG tags: From c2715ed77269bea1eb70d0d5e4b00e7d86eed854 Mon Sep 17 00:00:00 2001 From: jp Date: Tue, 31 Mar 2020 21:31:23 -0400 Subject: [PATCH 153/188] add imagemagick and update inherited container to alpine:3.11 --- .gitlab-ci.yml | 7 ++++--- Dockerfile | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e4bd8d282..6785c05f9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -288,7 +288,7 @@ docker: - export CI_VCS_REF=$CI_COMMIT_SHORT_SHA allow_failure: true script: - - docker build --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP -t $IMAGE_TAG -t $IMAGE_TAG_SLUG -t $IMAGE_TAG_LATEST . + - docker build --cache-from $IMAGE_TAG_SLUG --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP -t $IMAGE_TAG -t $IMAGE_TAG_SLUG -t $IMAGE_TAG_LATEST . - docker push $IMAGE_TAG - docker push $IMAGE_TAG_SLUG - docker push $IMAGE_TAG_LATEST @@ -296,6 +296,7 @@ docker: - dind only: - develop@pleroma/pleroma + - /^ops/.*$/@jp/pleroma docker-stable: stage: docker @@ -306,7 +307,7 @@ docker-stable: before_script: *before-docker allow_failure: true script: - - docker build --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP -t $IMAGE_TAG -t $IMAGE_TAG_SLUG -t $IMAGE_TAG_LATEST_STABLE . + - docker build --cache-from $IMAGE_TAG_SLUG --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP -t $IMAGE_TAG -t $IMAGE_TAG_SLUG -t $IMAGE_TAG_LATEST_STABLE . - docker push $IMAGE_TAG - docker push $IMAGE_TAG_SLUG - docker push $IMAGE_TAG_LATEST_STABLE @@ -324,7 +325,7 @@ docker-release: before_script: *before-docker allow_failure: true script: - - docker build --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP -t $IMAGE_TAG -t $IMAGE_TAG_SLUG . + - docker build --cache-from $IMAGE_TAG_SLUG --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP -t $IMAGE_TAG -t $IMAGE_TAG_SLUG . - docker push $IMAGE_TAG - docker push $IMAGE_TAG_SLUG tags: diff --git a/Dockerfile b/Dockerfile index b21f86fcd..29931a5e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN apk add git gcc g++ musl-dev make &&\ mkdir release &&\ mix release --path release -FROM alpine:3.9 +FROM alpine:3.11 ARG BUILD_DATE ARG VCS_REF From bcaaba4660c7f2f31756bbd64ed93fcd8e0b1d85 Mon Sep 17 00:00:00 2001 From: jp Date: Tue, 31 Mar 2020 22:16:36 -0400 Subject: [PATCH 154/188] remove testing `only:` in docker build --- .gitlab-ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6785c05f9..1b7c03ebb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -296,7 +296,6 @@ docker: - dind only: - develop@pleroma/pleroma - - /^ops/.*$/@jp/pleroma docker-stable: stage: docker From 94ddbe4098e167f9537d168261a6cc76fa17508b Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 1 Apr 2020 09:55:05 +0300 Subject: [PATCH 155/188] restrict remote users from indexing --- lib/pleroma/web/metadata.ex | 7 +++++- lib/pleroma/web/metadata/restrict_indexing.ex | 25 +++++++++++++++++++ test/web/metadata/metadata_test.exs | 25 +++++++++++++++++++ test/web/metadata/restrict_indexing_test.exs | 21 ++++++++++++++++ 4 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 lib/pleroma/web/metadata/restrict_indexing.ex create mode 100644 test/web/metadata/metadata_test.exs create mode 100644 test/web/metadata/restrict_indexing_test.exs diff --git a/lib/pleroma/web/metadata.ex b/lib/pleroma/web/metadata.ex index c9aac27dc..a9f70c43e 100644 --- a/lib/pleroma/web/metadata.ex +++ b/lib/pleroma/web/metadata.ex @@ -6,7 +6,12 @@ defmodule Pleroma.Web.Metadata do alias Phoenix.HTML def build_tags(params) do - Enum.reduce(Pleroma.Config.get([__MODULE__, :providers], []), "", fn parser, acc -> + providers = [ + Pleroma.Web.Metadata.Providers.RestrictIndexing + | Pleroma.Config.get([__MODULE__, :providers], []) + ] + + Enum.reduce(providers, "", fn parser, acc -> rendered_html = params |> parser.build_tags() diff --git a/lib/pleroma/web/metadata/restrict_indexing.ex b/lib/pleroma/web/metadata/restrict_indexing.ex new file mode 100644 index 000000000..f15607896 --- /dev/null +++ b/lib/pleroma/web/metadata/restrict_indexing.ex @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.Providers.RestrictIndexing do + @behaviour Pleroma.Web.Metadata.Providers.Provider + + @moduledoc """ + Restricts indexing of remote users. + """ + + @impl true + def build_tags(%{user: %{local: false}}) do + [ + {:meta, + [ + name: "robots", + content: "noindex, noarchive" + ], []} + ] + end + + @impl true + def build_tags(%{user: %{local: true}}), do: [] +end diff --git a/test/web/metadata/metadata_test.exs b/test/web/metadata/metadata_test.exs new file mode 100644 index 000000000..3f8b29e58 --- /dev/null +++ b/test/web/metadata/metadata_test.exs @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MetadataTest do + use Pleroma.DataCase, async: true + + import Pleroma.Factory + + describe "restrict indexing remote users" do + test "for remote user" do + user = insert(:user, local: false) + + assert Pleroma.Web.Metadata.build_tags(%{user: user}) =~ + "" + end + + test "for local user" do + user = insert(:user) + + refute Pleroma.Web.Metadata.build_tags(%{user: user}) =~ + "" + end + end +end diff --git a/test/web/metadata/restrict_indexing_test.exs b/test/web/metadata/restrict_indexing_test.exs new file mode 100644 index 000000000..aad0bac42 --- /dev/null +++ b/test/web/metadata/restrict_indexing_test.exs @@ -0,0 +1,21 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.Providers.RestrictIndexingTest do + use ExUnit.Case, async: true + + describe "build_tags/1" do + test "for remote user" do + assert Pleroma.Web.Metadata.Providers.RestrictIndexing.build_tags(%{ + user: %Pleroma.User{local: false} + }) == [{:meta, [name: "robots", content: "noindex, noarchive"], []}] + end + + test "for local user" do + assert Pleroma.Web.Metadata.Providers.RestrictIndexing.build_tags(%{ + user: %Pleroma.User{local: true} + }) == [] + end + end +end From 037b49c415060b4c7ad5a570da80857b4d2c43f1 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 1 Apr 2020 16:10:17 +0200 Subject: [PATCH 156/188] Validators: Correct ObjectID filename --- .../object_validators/types/{object.ex => object_id.ex} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename lib/pleroma/web/activity_pub/object_validators/types/{object.ex => object_id.ex} (100%) diff --git a/lib/pleroma/web/activity_pub/object_validators/types/object.ex b/lib/pleroma/web/activity_pub/object_validators/types/object_id.ex similarity index 100% rename from lib/pleroma/web/activity_pub/object_validators/types/object.ex rename to lib/pleroma/web/activity_pub/object_validators/types/object_id.ex From 2d64500a9dee8bc53c988719bde1c1f4f41575b7 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 1 Apr 2020 20:26:33 +0300 Subject: [PATCH 157/188] error improvement for email_invite endpoint --- docs/API/admin_api.md | 13 +++++++ .../web/admin_api/admin_api_controller.ex | 17 ++++++-- .../admin_api/admin_api_controller_test.exs | 39 ++++++++++++++++++- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index edcf73e14..179d8c451 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -392,6 +392,19 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret - `email` - `name`, optional +- Response: + - On success: `204`, empty response + - On failure: + - 400 Bad Request, JSON: + + ```json + [ + { + `error` // error message + } + ] + ``` + ## `GET /api/pleroma/admin/users/:nickname/password_reset` ### Get a password reset token for a given nickname diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index ca5439920..7b442f6e1 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -576,9 +576,8 @@ def relay_unfollow(%{assigns: %{user: admin}} = conn, %{"relay_url" => target}) @doc "Sends registration invite via email" def email_invite(%{assigns: %{user: user}} = conn, %{"email" => email} = params) do - with true <- - Config.get([:instance, :invites_enabled]) && - !Config.get([:instance, :registrations_open]), + with {_, false} <- {:registrations_open, Config.get([:instance, :registrations_open])}, + {_, true} <- {:invites_enabled, Config.get([:instance, :invites_enabled])}, {:ok, invite_token} <- UserInviteToken.create_invite(), email <- Pleroma.Emails.UserEmail.user_invitation_email( @@ -589,6 +588,18 @@ def email_invite(%{assigns: %{user: user}} = conn, %{"email" => email} = params) ), {:ok, _} <- Pleroma.Emails.Mailer.deliver(email) do json_response(conn, :no_content, "") + else + {:registrations_open, _} -> + errors( + conn, + {:error, "To send invites you need set `registrations_open` option to false."} + ) + + {:invites_enabled, _} -> + errors( + conn, + {:error, "To send invites you need set `invites_enabled` option to true."} + ) end end diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index ea0c92502..32fe69d19 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -625,6 +625,39 @@ test "it returns 403 if requested by a non-admin" do assert json_response(conn, :forbidden) end + + test "email with +", %{conn: conn, admin: admin} do + recipient_email = "foo+bar@baz.com" + + conn + |> put_req_header("content-type", "application/json;charset=utf-8") + |> post("/api/pleroma/admin/users/email_invite", %{email: recipient_email}) + |> json_response(:no_content) + + token_record = + Pleroma.UserInviteToken + |> Repo.all() + |> List.last() + + assert token_record + refute token_record.used + + notify_email = Config.get([:instance, :notify_email]) + instance_name = Config.get([:instance, :name]) + + email = + Pleroma.Emails.UserEmail.user_invitation_email( + admin, + token_record, + recipient_email + ) + + Swoosh.TestAssertions.assert_email_sent( + from: {instance_name, notify_email}, + to: recipient_email, + html_body: email.html_body + ) + end end describe "POST /api/pleroma/admin/users/email_invite, with invalid config" do @@ -637,7 +670,8 @@ test "it returns 500 if `invites_enabled` is not enabled", %{conn: conn} do conn = post(conn, "/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") - assert json_response(conn, :internal_server_error) + assert json_response(conn, :bad_request) == + "To send invites you need set `invites_enabled` option to true." end test "it returns 500 if `registrations_open` is enabled", %{conn: conn} do @@ -646,7 +680,8 @@ test "it returns 500 if `registrations_open` is enabled", %{conn: conn} do conn = post(conn, "/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") - assert json_response(conn, :internal_server_error) + assert json_response(conn, :bad_request) == + "To send invites you need set `registrations_open` option to false." end end From 23219e6fb3163bfac07fb5fb1b2602dcd27e47c2 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 1 Apr 2020 23:00:59 +0400 Subject: [PATCH 158/188] Add OpenAPI --- lib/pleroma/web/api_spec.ex | 30 ++++++ .../web/api_spec/operations/app_operation.ex | 94 +++++++++++++++++++ .../api_spec/schemas/app_create_request.ex | 33 +++++++ .../api_spec/schemas/app_create_response.ex | 33 +++++++ .../controllers/app_controller.ex | 9 +- lib/pleroma/web/oauth/scopes.ex | 7 +- lib/pleroma/web/router.ex | 11 +++ mix.exs | 3 +- mix.lock | 1 + test/web/api_spec/app_operation_test.exs | 45 +++++++++ .../controllers/account_controller_test.exs | 4 +- .../controllers/app_controller_test.exs | 4 +- 12 files changed, 266 insertions(+), 8 deletions(-) create mode 100644 lib/pleroma/web/api_spec.ex create mode 100644 lib/pleroma/web/api_spec/operations/app_operation.ex create mode 100644 lib/pleroma/web/api_spec/schemas/app_create_request.ex create mode 100644 lib/pleroma/web/api_spec/schemas/app_create_response.ex create mode 100644 test/web/api_spec/app_operation_test.exs diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex new file mode 100644 index 000000000..22f76d4bf --- /dev/null +++ b/lib/pleroma/web/api_spec.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 do + alias OpenApiSpex.OpenApi + alias Pleroma.Web.Endpoint + alias Pleroma.Web.Router + + @behaviour OpenApi + + @impl OpenApi + def spec do + %OpenApi{ + servers: [ + # Populate the Server info from a phoenix endpoint + OpenApiSpex.Server.from_endpoint(Endpoint) + ], + info: %OpenApiSpex.Info{ + title: "Pleroma", + description: Application.spec(:pleroma, :description) |> to_string(), + version: Application.spec(:pleroma, :vsn) |> to_string() + }, + # populate the paths from a phoenix router + paths: OpenApiSpex.Paths.from_router(Router) + } + # discover request/response schemas from path specs + |> OpenApiSpex.resolve_schema_modules() + end +end diff --git a/lib/pleroma/web/api_spec/operations/app_operation.ex b/lib/pleroma/web/api_spec/operations/app_operation.ex new file mode 100644 index 000000000..2a4958acf --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/app_operation.ex @@ -0,0 +1,94 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.AppOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.AppCreateRequest + alias Pleroma.Web.ApiSpec.Schemas.AppCreateResponse + + @spec open_api_operation(atom) :: Operation.t() + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + @spec create_operation() :: Operation.t() + def create_operation do + %Operation{ + tags: ["apps"], + summary: "Create an application", + description: "Create a new application to obtain OAuth2 credentials", + operationId: "AppController.create", + requestBody: + Operation.request_body("Parameters", "application/json", AppCreateRequest, required: true), + responses: %{ + 200 => Operation.response("App", "application/json", AppCreateResponse), + 422 => + Operation.response( + "Unprocessable Entity", + "application/json", + %Schema{ + type: :object, + description: + "If a required parameter is missing or improperly formatted, the request will fail.", + properties: %{ + error: %Schema{type: :string} + }, + example: %{ + "error" => "Validation failed: Redirect URI must be an absolute URI." + } + } + ) + } + } + end + + def verify_credentials_operation do + %Operation{ + tags: ["apps"], + summary: "Verify your app works", + description: "Confirm that the app's OAuth2 credentials work.", + operationId: "AppController.verify_credentials", + parameters: [ + Operation.parameter(:authorization, :header, :string, "Bearer ", required: true) + ], + responses: %{ + 200 => + Operation.response("App", "application/json", %Schema{ + type: :object, + description: + "If the Authorization header was provided with a valid token, you should see your app returned as an Application entity.", + properties: %{ + name: %Schema{type: :string}, + vapid_key: %Schema{type: :string}, + website: %Schema{type: :string, nullable: true} + }, + example: %{ + "name" => "My App", + "vapid_key" => + "BCk-QqERU0q-CfYZjcuB6lnyyOYfJ2AifKqfeGIm7Z-HiTU5T9eTG5GxVA0_OH5mMlI4UkkDTpaZwozy0TzdZ2M=", + "website" => "https://myapp.com/" + } + }), + 422 => + Operation.response( + "Unauthorized", + "application/json", + %Schema{ + type: :object, + description: + "If the Authorization header contains an invalid token, is malformed, or is not present, an error will be returned indicating an authorization failure.", + properties: %{ + error: %Schema{type: :string} + }, + example: %{ + "error" => "The access token is invalid." + } + } + ) + } + } + end +end diff --git a/lib/pleroma/web/api_spec/schemas/app_create_request.ex b/lib/pleroma/web/api_spec/schemas/app_create_request.ex new file mode 100644 index 000000000..8a83abef3 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/app_create_request.ex @@ -0,0 +1,33 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.AppCreateRequest do + alias OpenApiSpex.Schema + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "AppCreateRequest", + description: "POST body for creating an app", + type: :object, + properties: %{ + client_name: %Schema{type: :string, description: "A name for your application."}, + redirect_uris: %Schema{ + type: :string, + description: + "Where the user should be redirected after authorization. To display the authorization code to the user instead of redirecting to a web page, use `urn:ietf:wg:oauth:2.0:oob` in this parameter." + }, + scopes: %Schema{ + type: :string, + description: "Space separated list of scopes. If none is provided, defaults to `read`." + }, + website: %Schema{type: :string, description: "A URL to the homepage of your app"} + }, + required: [:client_name, :redirect_uris], + example: %{ + "client_name" => "My App", + "redirect_uris" => "https://myapp.com/auth/callback", + "website" => "https://myapp.com/" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/app_create_response.ex b/lib/pleroma/web/api_spec/schemas/app_create_response.ex new file mode 100644 index 000000000..f290fb031 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/app_create_response.ex @@ -0,0 +1,33 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.AppCreateResponse do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "AppCreateResponse", + description: "Response schema for an app", + type: :object, + properties: %{ + id: %Schema{type: :string}, + name: %Schema{type: :string}, + client_id: %Schema{type: :string}, + client_secret: %Schema{type: :string}, + redirect_uri: %Schema{type: :string}, + vapid_key: %Schema{type: :string}, + website: %Schema{type: :string, nullable: true} + }, + example: %{ + "id" => "123", + "name" => "My App", + "client_id" => "TWhM-tNSuncnqN7DBJmoyeLnk6K3iJJ71KKXxgL1hPM", + "client_secret" => "ZEaFUFmF0umgBX1qKJDjaU99Q31lDkOU8NutzTOoliw", + "vapid_key" => + "BCk-QqERU0q-CfYZjcuB6lnyyOYfJ2AifKqfeGIm7Z-HiTU5T9eTG5GxVA0_OH5mMlI4UkkDTpaZwozy0TzdZ2M=", + "website" => "https://myapp.com/" + } + }) +end diff --git a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex index 5e2871f18..005c60444 100644 --- a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex @@ -14,17 +14,20 @@ defmodule Pleroma.Web.MastodonAPI.AppController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :verify_credentials) + plug(OpenApiSpex.Plug.CastAndValidate) @local_mastodon_name "Mastodon-Local" + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.AppOperation + @doc "POST /api/v1/apps" - def create(conn, params) do + def create(%{body_params: params} = conn, _params) do scopes = Scopes.fetch_scopes(params, ["read"]) app_attrs = params - |> Map.drop(["scope", "scopes"]) - |> Map.put("scopes", scopes) + |> Map.take([:client_name, :redirect_uris, :website]) + |> Map.put(:scopes, scopes) with cs <- App.register_changeset(%App{}, app_attrs), false <- cs.changes[:client_name] == @local_mastodon_name, diff --git a/lib/pleroma/web/oauth/scopes.ex b/lib/pleroma/web/oauth/scopes.ex index 8ecf901f3..1023f16d4 100644 --- a/lib/pleroma/web/oauth/scopes.ex +++ b/lib/pleroma/web/oauth/scopes.ex @@ -15,7 +15,12 @@ defmodule Pleroma.Web.OAuth.Scopes do Note: `scopes` is used by Mastodon — supporting it but sticking to OAuth's standard `scope` wherever we control it """ - @spec fetch_scopes(map(), list()) :: list() + @spec fetch_scopes(map() | struct(), list()) :: list() + + def fetch_scopes(%Pleroma.Web.ApiSpec.Schemas.AppCreateRequest{scopes: scopes}, default) do + parse_scopes(scopes, default) + end + def fetch_scopes(params, default) do parse_scopes(params["scope"] || params["scopes"], default) end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 5a0902739..3ecd59cd1 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -29,6 +29,7 @@ defmodule Pleroma.Web.Router do plug(Pleroma.Plugs.SetUserSessionIdPlug) plug(Pleroma.Plugs.EnsureUserKeyPlug) plug(Pleroma.Plugs.IdempotencyPlug) + plug(OpenApiSpex.Plug.PutApiSpec, module: Pleroma.Web.ApiSpec) end pipeline :authenticated_api do @@ -44,6 +45,7 @@ defmodule Pleroma.Web.Router do plug(Pleroma.Plugs.SetUserSessionIdPlug) plug(Pleroma.Plugs.EnsureAuthenticatedPlug) plug(Pleroma.Plugs.IdempotencyPlug) + plug(OpenApiSpex.Plug.PutApiSpec, module: Pleroma.Web.ApiSpec) end pipeline :admin_api do @@ -61,6 +63,7 @@ defmodule Pleroma.Web.Router do plug(Pleroma.Plugs.EnsureAuthenticatedPlug) plug(Pleroma.Plugs.UserIsAdminPlug) plug(Pleroma.Plugs.IdempotencyPlug) + plug(OpenApiSpex.Plug.PutApiSpec, module: Pleroma.Web.ApiSpec) end pipeline :mastodon_html do @@ -94,10 +97,12 @@ defmodule Pleroma.Web.Router do pipeline :config do plug(:accepts, ["json", "xml"]) + plug(OpenApiSpex.Plug.PutApiSpec, module: Pleroma.Web.ApiSpec) end pipeline :pleroma_api do plug(:accepts, ["html", "json"]) + plug(OpenApiSpex.Plug.PutApiSpec, module: Pleroma.Web.ApiSpec) end pipeline :mailbox_preview do @@ -500,6 +505,12 @@ defmodule Pleroma.Web.Router do ) end + scope "/api" do + pipe_through(:api) + + get("/openapi", OpenApiSpex.Plug.RenderSpec, []) + end + scope "/api", Pleroma.Web, as: :authenticated_twitter_api do pipe_through(:authenticated_api) diff --git a/mix.exs b/mix.exs index 890979f8b..ebd4a5ea6 100644 --- a/mix.exs +++ b/mix.exs @@ -171,7 +171,8 @@ defp deps do git: "https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha.git", ref: "e0f16822d578866e186a0974d65ad58cddc1e2ab"}, {:mox, "~> 0.5", only: :test}, - {:restarter, path: "./restarter"} + {:restarter, path: "./restarter"}, + {:open_api_spex, "~> 3.6"} ] ++ oauth_deps() end diff --git a/mix.lock b/mix.lock index 62e14924a..fd26ca01b 100644 --- a/mix.lock +++ b/mix.lock @@ -72,6 +72,7 @@ "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, "0.12.1", "695e9490c6e0edfca616d80639528e448bd29b3bff7b7dd10a56c79b00a5d7fb", [: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", "c1d58d69b8b5a86e7167abbb8cc92764a66f25f12f6172052595067fc6a30a17"}, + "open_api_spex": {:hex, :open_api_spex, "3.6.0", "64205aba9f2607f71b08fd43e3351b9c5e9898ec5ef49fc0ae35890da502ade9", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.1", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "126ba3473966277132079cb1d5bf1e3df9e36fe2acd00166e75fd125cecb59c5"}, "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"}, diff --git a/test/web/api_spec/app_operation_test.exs b/test/web/api_spec/app_operation_test.exs new file mode 100644 index 000000000..5b96abb44 --- /dev/null +++ b/test/web/api_spec/app_operation_test.exs @@ -0,0 +1,45 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.AppOperationTest do + use Pleroma.Web.ConnCase, async: true + + alias Pleroma.Web.ApiSpec + alias Pleroma.Web.ApiSpec.Schemas.AppCreateRequest + alias Pleroma.Web.ApiSpec.Schemas.AppCreateResponse + + import OpenApiSpex.TestAssertions + import Pleroma.Factory + + test "AppCreateRequest example matches schema" do + api_spec = ApiSpec.spec() + schema = AppCreateRequest.schema() + assert_schema(schema.example, "AppCreateRequest", api_spec) + end + + test "AppCreateResponse example matches schema" do + api_spec = ApiSpec.spec() + schema = AppCreateResponse.schema() + assert_schema(schema.example, "AppCreateResponse", api_spec) + end + + test "AppController produces a AppCreateResponse", %{conn: conn} do + api_spec = ApiSpec.spec() + app_attrs = build(:oauth_app) + + json = + conn + |> put_req_header("content-type", "application/json") + |> post( + "/api/v1/apps", + Jason.encode!(%{ + client_name: app_attrs.client_name, + redirect_uris: app_attrs.redirect_uris + }) + ) + |> json_response(200) + + assert_schema(json, "AppCreateResponse", api_spec) + end +end diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index a9fa0ce48..a450a732c 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -794,7 +794,9 @@ test "blocking / unblocking a user" do test "Account registration via Application", %{conn: conn} do conn = - post(conn, "/api/v1/apps", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/apps", %{ client_name: "client_name", redirect_uris: "urn:ietf:wg:oauth:2.0:oob", scopes: "read, write, follow" diff --git a/test/web/mastodon_api/controllers/app_controller_test.exs b/test/web/mastodon_api/controllers/app_controller_test.exs index 77d234d67..e7b11d14e 100644 --- a/test/web/mastodon_api/controllers/app_controller_test.exs +++ b/test/web/mastodon_api/controllers/app_controller_test.exs @@ -16,8 +16,7 @@ test "apps/verify_credentials", %{conn: conn} do conn = conn - |> assign(:user, token.user) - |> assign(:token, token) + |> put_req_header("authorization", "Bearer #{token.token}") |> get("/api/v1/apps/verify_credentials") app = Repo.preload(token, :app).app @@ -37,6 +36,7 @@ test "creates an oauth app", %{conn: conn} do conn = conn + |> put_req_header("content-type", "application/json") |> assign(:user, user) |> post("/api/v1/apps", %{ client_name: app_attrs.client_name, From 0aa24a150bbb153f55ca92dfb595385b4fe3839c Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 2 Apr 2020 17:33:23 +0400 Subject: [PATCH 159/188] Add oAuth --- lib/pleroma/web/api_spec.ex | 16 +++++++++++++++- .../web/api_spec/operations/app_operation.ex | 6 ++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex index 22f76d4bf..41e48a085 100644 --- a/lib/pleroma/web/api_spec.ex +++ b/lib/pleroma/web/api_spec.ex @@ -22,7 +22,21 @@ def spec do version: Application.spec(:pleroma, :vsn) |> to_string() }, # populate the paths from a phoenix router - paths: OpenApiSpex.Paths.from_router(Router) + paths: OpenApiSpex.Paths.from_router(Router), + components: %OpenApiSpex.Components{ + securitySchemes: %{ + "oAuth" => %OpenApiSpex.SecurityScheme{ + type: "oauth2", + flows: %OpenApiSpex.OAuthFlows{ + password: %OpenApiSpex.OAuthFlow{ + authorizationUrl: "/oauth/authorize", + tokenUrl: "/oauth/token", + scopes: %{"read" => "read"} + } + } + } + } + } } # discover request/response schemas from path specs |> OpenApiSpex.resolve_schema_modules() diff --git a/lib/pleroma/web/api_spec/operations/app_operation.ex b/lib/pleroma/web/api_spec/operations/app_operation.ex index 2a4958acf..41d56693a 100644 --- a/lib/pleroma/web/api_spec/operations/app_operation.ex +++ b/lib/pleroma/web/api_spec/operations/app_operation.ex @@ -51,8 +51,10 @@ def verify_credentials_operation do summary: "Verify your app works", description: "Confirm that the app's OAuth2 credentials work.", operationId: "AppController.verify_credentials", - parameters: [ - Operation.parameter(:authorization, :header, :string, "Bearer ", required: true) + security: [ + %{ + "oAuth" => ["read"] + } ], responses: %{ 200 => From aa78325117c879ecb7ec76383c239078275adbd9 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 2 Apr 2020 19:23:30 +0300 Subject: [PATCH 160/188] [#2323] Fixed a typo causing /accounts/relationships to render default relationships. Improved the tests. --- lib/pleroma/web/mastodon_api/views/account_view.ex | 8 +++++--- .../web/mastodon_api/views/notification_view.ex | 2 +- lib/pleroma/web/mastodon_api/views/status_view.ex | 10 ++++++---- test/web/mastodon_api/views/account_view_test.exs | 3 +++ 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index c482bba64..99e62f580 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -13,16 +13,18 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do alias Pleroma.Web.MediaProxy def render("index.json", %{users: users} = opts) do + reading_user = opts[:for] + relationships_opt = cond do Map.has_key?(opts, :relationships) -> opts[:relationships] - is_nil(opts[:for]) -> + is_nil(reading_user) -> UserRelationship.view_relationships_option(nil, []) true -> - UserRelationship.view_relationships_option(opts[:for], users) + UserRelationship.view_relationships_option(reading_user, users) end opts = Map.put(opts, :relationships, relationships_opt) @@ -143,7 +145,7 @@ def render("relationships.json", %{user: user, targets: targets} = opts) do Map.has_key?(opts, :relationships) -> opts[:relationships] - is_nil(opts[:for]) -> + is_nil(user) -> UserRelationship.view_relationships_option(nil, []) true -> diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 89f5734ff..ae87d4701 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -36,7 +36,7 @@ def render("index.json", %{notifications: notifications, for: reading_user} = op Map.has_key?(opts, :relationships) -> opts[:relationships] - is_nil(opts[:for]) -> + is_nil(reading_user) -> UserRelationship.view_relationships_option(nil, []) true -> diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 82326986c..cea76e735 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -72,6 +72,8 @@ defp reblogged?(activity, user) do end def render("index.json", opts) do + reading_user = opts[:for] + # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list activities = Enum.filter(opts.activities, & &1) replied_to_activities = get_replied_to_activities(activities) @@ -82,8 +84,8 @@ def render("index.json", opts) do |> Enum.map(&Object.normalize(&1).data["id"]) |> Activity.create_by_object_ap_id() |> Activity.with_preloaded_object(:left) - |> Activity.with_preloaded_bookmark(opts[:for]) - |> Activity.with_set_thread_muted_field(opts[:for]) + |> Activity.with_preloaded_bookmark(reading_user) + |> Activity.with_set_thread_muted_field(reading_user) |> Repo.all() relationships_opt = @@ -91,13 +93,13 @@ def render("index.json", opts) do Map.has_key?(opts, :relationships) -> opts[:relationships] - is_nil(opts[:for]) -> + is_nil(reading_user) -> UserRelationship.view_relationships_option(nil, []) true -> actors = Enum.map(activities ++ parent_activities, &get_user(&1.data["actor"])) - UserRelationship.view_relationships_option(opts[:for], actors) + UserRelationship.view_relationships_option(reading_user, actors) end opts = diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 8d00e3c21..4435f69ff 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -209,6 +209,9 @@ defp test_relationship_rendering(user, other_user, expected_result) do relationships_opt = UserRelationship.view_relationships_option(user, [other_user]) opts = Map.put(opts, :relationships, relationships_opt) assert expected_result == AccountView.render("relationship.json", opts) + + assert [expected_result] == + AccountView.render("relationships.json", %{user: user, targets: [other_user]}) end @blank_response %{ From 8a0ffaa9ead2574707cb45c014cb421ff31f7a03 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 2 Apr 2020 23:01:29 +0400 Subject: [PATCH 161/188] Fix formatting in documentation --- docs/API/differences_in_mastoapi_responses.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index dc8f54d2a..1059155cf 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -164,6 +164,7 @@ Additional parameters can be added to the JSON body/Form data: - `actor_type` - the type of this account. ### Pleroma Settings Store + Pleroma has mechanism that allows frontends to save blobs of json for each user on the backend. This can be used to save frontend-specific settings for a user that the backend does not need to know about. The parameter should have a form of `{frontend_name: {...}}`, with `frontend_name` identifying your type of client, e.g. `pleroma_fe`. It will overwrite everything under this property, but will not overwrite other frontend's settings. @@ -172,17 +173,20 @@ This information is returned in the `verify_credentials` endpoint. ## Authentication -*Pleroma supports refreshing tokens. +*Pleroma supports refreshing tokens.* `POST /oauth/token` -Post here request with grant_type=refresh_token to obtain new access token. Returns an access token. + +Post here request with `grant_type=refresh_token` to obtain new access token. Returns an access token. ## Account Registration + `POST /api/v1/accounts` Has theses additional parameters (which are the same as in Pleroma-API): - * `fullname`: optional - * `bio`: optional - * `captcha_solution`: optional, contains provider-specific captcha solution, - * `captcha_token`: optional, contains provider-specific captcha token - * `token`: invite token required when the registerations aren't public. + +- `fullname`: optional +- `bio`: optional +- `captcha_solution`: optional, contains provider-specific captcha solution, +- `captcha_token`: optional, contains provider-specific captcha token +- `token`: invite token required when the registrations aren't public. From b59ac37b2c09d5dc80b59bd3a2aea36989bee713 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 6 Apr 2020 10:45:25 +0300 Subject: [PATCH 162/188] tests for emoji mix task --- coveralls.json | 6 + docs/administration/CLI_tasks/emoji.md | 4 +- lib/mix/tasks/pleroma/emoji.ex | 90 ++++--- test/fixtures/emoji/packs/blank.png.zip | Bin 0 -> 284 bytes .../emoji/packs/default-manifest.json | 10 + test/fixtures/emoji/packs/finmoji.json | 3 + test/fixtures/emoji/packs/manifest.json | 10 + test/tasks/emoji_test.exs | 226 ++++++++++++++++++ 8 files changed, 311 insertions(+), 38 deletions(-) create mode 100644 coveralls.json create mode 100644 test/fixtures/emoji/packs/blank.png.zip create mode 100644 test/fixtures/emoji/packs/default-manifest.json create mode 100644 test/fixtures/emoji/packs/finmoji.json create mode 100644 test/fixtures/emoji/packs/manifest.json create mode 100644 test/tasks/emoji_test.exs diff --git a/coveralls.json b/coveralls.json new file mode 100644 index 000000000..75e845ade --- /dev/null +++ b/coveralls.json @@ -0,0 +1,6 @@ +{ + "skip_files": [ + "test/support", + "lib/mix/tasks/pleroma/benchmark.ex" + ] +} \ No newline at end of file diff --git a/docs/administration/CLI_tasks/emoji.md b/docs/administration/CLI_tasks/emoji.md index efec8222c..3d524a52b 100644 --- a/docs/administration/CLI_tasks/emoji.md +++ b/docs/administration/CLI_tasks/emoji.md @@ -39,8 +39,8 @@ mix pleroma.emoji get-packs [option ...] mix pleroma.emoji gen-pack PACK-URL ``` -Currently, only .zip archives are recognized as remote pack files and packs are therefore assumed to be zip archives. This command is intended to run interactively and will first ask you some basic questions about the pack, then download the remote file and generate an SHA256 checksum for it, then generate an emoji file list for you. +Currently, only .zip archives are recognized as remote pack files and packs are therefore assumed to be zip archives. This command is intended to run interactively and will first ask you some basic questions about the pack, then download the remote file and generate an SHA256 checksum for it, then generate an emoji file list for you. - The manifest entry will either be written to a newly created `index.json` file or appended to the existing one, *replacing* the old pack with the same name if it was in the file previously. + The manifest entry will either be written to a newly created `pack_name.json` file (pack name is asked in questions) or appended to the existing one, *replacing* the old pack with the same name if it was in the file previously. The file list will be written to the file specified previously, *replacing* that file. You _should_ check that the file list doesn't contain anything you don't need in the pack, that is, anything that is not an emoji (the whole pack is downloaded, but only emoji files are extracted). diff --git a/lib/mix/tasks/pleroma/emoji.ex b/lib/mix/tasks/pleroma/emoji.ex index 429d763c7..cdffa88b2 100644 --- a/lib/mix/tasks/pleroma/emoji.ex +++ b/lib/mix/tasks/pleroma/emoji.ex @@ -14,8 +14,8 @@ def run(["ls-packs" | args]) do {options, [], []} = parse_global_opts(args) - manifest = - fetch_manifest(if options[:manifest], do: options[:manifest], else: default_manifest()) + url_or_path = options[:manifest] || default_manifest() + manifest = fetch_manifest(url_or_path) Enum.each(manifest, fn {name, info} -> to_print = [ @@ -40,9 +40,9 @@ def run(["get-packs" | args]) do {options, pack_names, []} = parse_global_opts(args) - manifest_url = if options[:manifest], do: options[:manifest], else: default_manifest() + url_or_path = options[:manifest] || default_manifest() - manifest = fetch_manifest(manifest_url) + manifest = fetch_manifest(url_or_path) for pack_name <- pack_names do if Map.has_key?(manifest, pack_name) do @@ -75,7 +75,10 @@ def run(["get-packs" | args]) do end # The url specified in files should be in the same directory - files_url = Path.join(Path.dirname(manifest_url), pack["files"]) + files_url = + url_or_path + |> Path.dirname() + |> Path.join(pack["files"]) IO.puts( IO.ANSI.format([ @@ -133,38 +136,51 @@ def run(["get-packs" | args]) do end end - def run(["gen-pack", src]) do + def run(["gen-pack" | args]) do start_pleroma() - proposed_name = Path.basename(src) |> Path.rootname() - name = String.trim(IO.gets("Pack name [#{proposed_name}]: ")) - # If there's no name, use the default one - name = if String.length(name) > 0, do: name, else: proposed_name - - license = String.trim(IO.gets("License: ")) - homepage = String.trim(IO.gets("Homepage: ")) - description = String.trim(IO.gets("Description: ")) - - proposed_files_name = "#{name}.json" - files_name = String.trim(IO.gets("Save file list to [#{proposed_files_name}]: ")) - files_name = if String.length(files_name) > 0, do: files_name, else: proposed_files_name - - default_exts = [".png", ".gif"] - default_exts_str = Enum.join(default_exts, " ") - - exts = - String.trim( - IO.gets("Emoji file extensions (separated with spaces) [#{default_exts_str}]: ") + {opts, [src], []} = + OptionParser.parse( + args, + strict: [ + name: :string, + license: :string, + homepage: :string, + description: :string, + files: :string, + extensions: :string + ] ) + proposed_name = Path.basename(src) |> Path.rootname() + name = get_option(opts, :name, "Pack name:", proposed_name) + license = get_option(opts, :license, "License:") + homepage = get_option(opts, :homepage, "Homepage:") + description = get_option(opts, :description, "Description:") + + proposed_files_name = "#{name}_files.json" + files_name = get_option(opts, :files, "Save file list to:", proposed_files_name) + + default_exts = [".png", ".gif"] + + custom_exts = + get_option( + opts, + :extensions, + "Emoji file extensions (separated with spaces):", + Enum.join(default_exts, " ") + ) + |> String.split(" ", trim: true) + exts = - if String.length(exts) > 0 do - String.split(exts, " ") - |> Enum.filter(fn e -> e |> String.trim() |> String.length() > 0 end) - else + if MapSet.equal?(MapSet.new(default_exts), MapSet.new(custom_exts)) do default_exts + else + custom_exts end + IO.puts("Using #{Enum.join(exts, " ")} extensions") + IO.puts("Downloading the pack and generating SHA256") binary_archive = Tesla.get!(client(), src).body @@ -194,14 +210,16 @@ def run(["gen-pack", src]) do IO.puts(""" #{files_name} has been created and contains the list of all found emojis in the pack. - Please review the files in the remove those not needed. + Please review the files in the pack and remove those not needed. """) - if File.exists?("index.json") do - existing_data = File.read!("index.json") |> Jason.decode!() + pack_file = "#{name}.json" + + if File.exists?(pack_file) do + existing_data = File.read!(pack_file) |> Jason.decode!() File.write!( - "index.json", + pack_file, Jason.encode!( Map.merge( existing_data, @@ -211,11 +229,11 @@ def run(["gen-pack", src]) do ) ) - IO.puts("index.json file has been update with the #{name} pack") + IO.puts("#{pack_file} has been updated with the #{name} pack") else - File.write!("index.json", Jason.encode!(pack_json, pretty: true)) + File.write!(pack_file, Jason.encode!(pack_json, pretty: true)) - IO.puts("index.json has been created with the #{name} pack") + IO.puts("#{pack_file} has been created with the #{name} pack") end end diff --git a/test/fixtures/emoji/packs/blank.png.zip b/test/fixtures/emoji/packs/blank.png.zip new file mode 100644 index 0000000000000000000000000000000000000000..651daf1271fb95ca1404142441360eed4fbdd45d GIT binary patch literal 284 zcmWIWW@Zs#-~d9a?B)OlD2NBroD2#KNjZsm*?I+e>7gOK4D5#d?QteR45CXbxEUB( zzA`c}0JSqPyyp2({QT*pM@b0@559gW;AFbQt8j)xMIvtZu_cU}%O)x8Phe}?bTVDH z!G_ac{P+Z}V^3I*39acCcBz`h#Jl4TUzUMEfr)`Z-T9eS0!uDmzIr}&Zm^RuLx49s yM|^mrXavv>kfQ>;8JR?w5e`O{134H5mNbG`L_0sgo0Scufe{G9f%JM1hXDXq!$&v( literal 0 HcmV?d00001 diff --git a/test/fixtures/emoji/packs/default-manifest.json b/test/fixtures/emoji/packs/default-manifest.json new file mode 100644 index 000000000..c8433808d --- /dev/null +++ b/test/fixtures/emoji/packs/default-manifest.json @@ -0,0 +1,10 @@ +{ + "finmoji": { + "license": "CC BY-NC-ND 4.0", + "homepage": "https://finland.fi/emoji/", + "description": "Finland is the first country in the world to publish its own set of country themed emojis. The Finland emoji collection contains 56 tongue-in-cheek emotions, which were created to explain some hard-to-describe Finnish emotions, Finnish words and customs.", + "src": "https://finland.fi/wp-content/uploads/2017/06/finland-emojis.zip", + "src_sha256": "384025A1AC6314473863A11AC7AB38A12C01B851A3F82359B89B4D4211D3291D", + "files": "finmoji.json" + } +} \ No newline at end of file diff --git a/test/fixtures/emoji/packs/finmoji.json b/test/fixtures/emoji/packs/finmoji.json new file mode 100644 index 000000000..279770998 --- /dev/null +++ b/test/fixtures/emoji/packs/finmoji.json @@ -0,0 +1,3 @@ +{ + "blank": "blank.png" +} \ No newline at end of file diff --git a/test/fixtures/emoji/packs/manifest.json b/test/fixtures/emoji/packs/manifest.json new file mode 100644 index 000000000..2d51a459b --- /dev/null +++ b/test/fixtures/emoji/packs/manifest.json @@ -0,0 +1,10 @@ +{ + "blobs.gg": { + "src_sha256": "3a12f3a181678d5b3584a62095411b0d60a335118135910d879920f8ade5a57f", + "src": "https://git.pleroma.social/pleroma/emoji-index/raw/master/packs/blobs_gg.zip", + "license": "Apache 2.0", + "homepage": "https://blobs.gg", + "files": "blobs_gg.json", + "description": "Blob Emoji from blobs.gg repacked as apng" + } +} \ No newline at end of file diff --git a/test/tasks/emoji_test.exs b/test/tasks/emoji_test.exs new file mode 100644 index 000000000..f2930652a --- /dev/null +++ b/test/tasks/emoji_test.exs @@ -0,0 +1,226 @@ +defmodule Mix.Tasks.Pleroma.EmojiTest do + use ExUnit.Case, async: true + + import ExUnit.CaptureIO + import Tesla.Mock + + alias Mix.Tasks.Pleroma.Emoji + + describe "ls-packs" do + test "with default manifest as url" do + mock(fn + %{ + method: :get, + url: "https://git.pleroma.social/pleroma/emoji-index/raw/master/index.json" + } -> + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/emoji/packs/default-manifest.json") + } + end) + + capture_io(fn -> Emoji.run(["ls-packs"]) end) =~ + "https://finland.fi/wp-content/uploads/2017/06/finland-emojis.zip" + end + + test "with passed manifest as file" do + capture_io(fn -> + Emoji.run(["ls-packs", "-m", "test/fixtures/emoji/packs/manifest.json"]) + end) =~ "https://git.pleroma.social/pleroma/emoji-index/raw/master/packs/blobs_gg.zip" + end + end + + describe "get-packs" do + test "download pack from default manifest" do + mock(fn + %{ + method: :get, + url: "https://git.pleroma.social/pleroma/emoji-index/raw/master/index.json" + } -> + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/emoji/packs/default-manifest.json") + } + + %{ + method: :get, + url: "https://finland.fi/wp-content/uploads/2017/06/finland-emojis.zip" + } -> + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/emoji/packs/blank.png.zip") + } + + %{ + method: :get, + url: "https://git.pleroma.social/pleroma/emoji-index/raw/master/finmoji.json" + } -> + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/emoji/packs/finmoji.json") + } + end) + + assert capture_io(fn -> Emoji.run(["get-packs", "finmoji"]) end) =~ "Writing pack.json for" + + emoji_path = + Path.join( + Pleroma.Config.get!([:instance, :static_dir]), + "emoji" + ) + + assert File.exists?(Path.join([emoji_path, "finmoji", "pack.json"])) + on_exit(fn -> File.rm_rf!("test/instance_static/emoji/finmoji") end) + end + + test "pack not found" do + mock(fn + %{ + method: :get, + url: "https://git.pleroma.social/pleroma/emoji-index/raw/master/index.json" + } -> + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/emoji/packs/default-manifest.json") + } + end) + + assert capture_io(fn -> Emoji.run(["get-packs", "not_found"]) end) =~ + "No pack named \"not_found\" found" + end + + test "raise on bad sha256" do + mock(fn + %{ + method: :get, + url: "https://git.pleroma.social/pleroma/emoji-index/raw/master/packs/blobs_gg.zip" + } -> + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/emoji/packs/blank.png.zip") + } + end) + + assert_raise RuntimeError, ~r/^Bad SHA256 for blobs.gg/, fn -> + capture_io(fn -> + Emoji.run(["get-packs", "blobs.gg", "-m", "test/fixtures/emoji/packs/manifest.json"]) + end) + end + end + end + + describe "gen-pack" do + setup do + url = "https://finland.fi/wp-content/uploads/2017/06/finland-emojis.zip" + + mock(fn %{ + method: :get, + url: ^url + } -> + %Tesla.Env{status: 200, body: File.read!("test/fixtures/emoji/packs/blank.png.zip")} + end) + + {:ok, url: url} + end + + test "with default extensions", %{url: url} do + name = "pack1" + pack_json = "#{name}.json" + files_json = "#{name}_file.json" + refute File.exists?(pack_json) + refute File.exists?(files_json) + + captured = + capture_io(fn -> + Emoji.run([ + "gen-pack", + url, + "--name", + name, + "--license", + "license", + "--homepage", + "homepage", + "--description", + "description", + "--files", + files_json, + "--extensions", + ".png .gif" + ]) + end) + + assert captured =~ "#{pack_json} has been created with the pack1 pack" + assert captured =~ "Using .png .gif extensions" + + assert File.exists?(pack_json) + assert File.exists?(files_json) + + on_exit(fn -> + File.rm_rf!(pack_json) + File.rm_rf!(files_json) + end) + end + + test "with custom extensions and update existing files", %{url: url} do + name = "pack2" + pack_json = "#{name}.json" + files_json = "#{name}_file.json" + refute File.exists?(pack_json) + refute File.exists?(files_json) + + captured = + capture_io(fn -> + Emoji.run([ + "gen-pack", + url, + "--name", + name, + "--license", + "license", + "--homepage", + "homepage", + "--description", + "description", + "--files", + files_json, + "--extensions", + " .png .gif .jpeg " + ]) + end) + + assert captured =~ "#{pack_json} has been created with the pack2 pack" + assert captured =~ "Using .png .gif .jpeg extensions" + + assert File.exists?(pack_json) + assert File.exists?(files_json) + + captured = + capture_io(fn -> + Emoji.run([ + "gen-pack", + url, + "--name", + name, + "--license", + "license", + "--homepage", + "homepage", + "--description", + "description", + "--files", + files_json, + "--extensions", + " .png .gif .jpeg " + ]) + end) + + assert captured =~ "#{pack_json} has been updated with the pack2 pack" + + on_exit(fn -> + File.rm_rf!(pack_json) + File.rm_rf!(files_json) + end) + end + end +end From a43e05591639132ce121d2e14258944a53004438 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 6 Apr 2020 14:27:20 +0300 Subject: [PATCH 163/188] using another fn for file deletion --- test/tasks/emoji_test.exs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/tasks/emoji_test.exs b/test/tasks/emoji_test.exs index f2930652a..f5de3ef0e 100644 --- a/test/tasks/emoji_test.exs +++ b/test/tasks/emoji_test.exs @@ -157,8 +157,8 @@ test "with default extensions", %{url: url} do assert File.exists?(files_json) on_exit(fn -> - File.rm_rf!(pack_json) - File.rm_rf!(files_json) + File.rm!(pack_json) + File.rm!(files_json) end) end @@ -218,8 +218,8 @@ test "with custom extensions and update existing files", %{url: url} do assert captured =~ "#{pack_json} has been updated with the pack2 pack" on_exit(fn -> - File.rm_rf!(pack_json) - File.rm_rf!(files_json) + File.rm!(pack_json) + File.rm!(files_json) end) end end From e67cde0ed6b55450b5f309f9ed86f7f8e2a1e73f Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Apr 2020 13:46:34 +0200 Subject: [PATCH 164/188] Transmogrifier: Refactoring / Renaming. --- lib/pleroma/web/activity_pub/transmogrifier.ex | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index a4b385cd5..455f51fe0 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -617,9 +617,9 @@ def handle_incoming(%{"type" => "Like"} = data, _options) do data |> LikeValidator.cast_data() |> Ecto.Changeset.apply_action(:insert)}, cast_data = ObjectValidator.stringify_keys(Map.from_struct(cast_data_sym)), :ok <- ObjectValidator.fetch_actor_and_object(cast_data), - {_, {:ok, cast_data}} <- {:maybe_add_context, maybe_add_context_from_object(cast_data)}, + {_, {:ok, cast_data}} <- {:ensure_context_presence, ensure_context_presence(cast_data)}, {_, {:ok, cast_data}} <- - {:maybe_add_recipients, maybe_add_recipients_from_object(cast_data)}, + {:ensure_recipients_presence, ensure_recipients_presence(cast_data)}, {_, {:ok, activity, _meta}} <- {:common_pipeline, Pipeline.common_pipeline(cast_data, local: false)} do {:ok, activity} @@ -1251,10 +1251,10 @@ def maybe_fix_user_url(data), do: data def maybe_fix_user_object(data), do: maybe_fix_user_url(data) - defp maybe_add_context_from_object(%{"context" => context} = data) when is_binary(context), + defp ensure_context_presence(%{"context" => context} = data) when is_binary(context), do: {:ok, data} - defp maybe_add_context_from_object(%{"object" => object} = data) when is_binary(object) do + defp ensure_context_presence(%{"object" => object} = data) when is_binary(object) do with %{data: %{"context" => context}} when is_binary(context) <- Object.normalize(object) do {:ok, Map.put(data, "context", context)} else @@ -1263,14 +1263,14 @@ defp maybe_add_context_from_object(%{"object" => object} = data) when is_binary( end end - defp maybe_add_context_from_object(_) do + defp ensure_context_presence(_) do {:error, :no_context} end - defp maybe_add_recipients_from_object(%{"to" => [_ | _], "cc" => [_ | _]} = data), + defp ensure_recipients_presence(%{"to" => [_ | _], "cc" => [_ | _]} = data), do: {:ok, data} - defp maybe_add_recipients_from_object(%{"object" => object} = data) do + defp ensure_recipients_presence(%{"object" => object} = data) do case Object.normalize(object) do %{data: %{"actor" => actor}} -> data = @@ -1288,7 +1288,7 @@ defp maybe_add_recipients_from_object(%{"object" => object} = data) do end end - defp maybe_add_recipients_from_object(_) do + defp ensure_recipients_presence(_) do {:error, :no_object} end end From 772bc258cde11b3203ad9420f69321ccd56db91a Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Apr 2020 13:53:24 +0200 Subject: [PATCH 165/188] ObjectID Validator: Refactor. --- .../object_validators/types/object_id.ex | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/types/object_id.ex b/lib/pleroma/web/activity_pub/object_validators/types/object_id.ex index 8e70effe4..ee10be0b0 100644 --- a/lib/pleroma/web/activity_pub/object_validators/types/object_id.ex +++ b/lib/pleroma/web/activity_pub/object_validators/types/object_id.ex @@ -4,14 +4,14 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID do def type, do: :string def cast(object) when is_binary(object) do - with %URI{ - scheme: scheme, - host: host - } - when scheme in ["https", "http"] and not is_nil(host) <- - URI.parse(object) do - {:ok, object} - else + # Host has to be present and scheme has to be an http scheme (for now) + case URI.parse(object) do + %URI{host: nil} -> + :error + + %URI{scheme: scheme} when scheme in ["https", "http"] -> + {:ok, object} + _ -> :error end From 03eebabe8e5b2e3f96f6ffe51a6f063a42f6a5d2 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 3 Apr 2020 22:52:25 +0400 Subject: [PATCH 166/188] Add Pleroma.Web.ApiSpec.Helpers --- lib/pleroma/web/api_spec/helpers.ex | 27 +++++++++++++++++++ .../web/api_spec/operations/app_operation.ex | 4 +-- 2 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 lib/pleroma/web/api_spec/helpers.ex diff --git a/lib/pleroma/web/api_spec/helpers.ex b/lib/pleroma/web/api_spec/helpers.ex new file mode 100644 index 000000000..35cf4c0d8 --- /dev/null +++ b/lib/pleroma/web/api_spec/helpers.ex @@ -0,0 +1,27 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Helpers do + def request_body(description, schema_ref, opts \\ []) do + media_types = ["application/json", "multipart/form-data"] + + content = + media_types + |> Enum.map(fn type -> + {type, + %OpenApiSpex.MediaType{ + schema: schema_ref, + example: opts[:example], + examples: opts[:examples] + }} + end) + |> Enum.into(%{}) + + %OpenApiSpex.RequestBody{ + description: description, + content: content, + required: opts[:required] || false + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/app_operation.ex b/lib/pleroma/web/api_spec/operations/app_operation.ex index 41d56693a..26d8dbd42 100644 --- a/lib/pleroma/web/api_spec/operations/app_operation.ex +++ b/lib/pleroma/web/api_spec/operations/app_operation.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ApiSpec.AppOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers alias Pleroma.Web.ApiSpec.Schemas.AppCreateRequest alias Pleroma.Web.ApiSpec.Schemas.AppCreateResponse @@ -21,8 +22,7 @@ def create_operation do summary: "Create an application", description: "Create a new application to obtain OAuth2 credentials", operationId: "AppController.create", - requestBody: - Operation.request_body("Parameters", "application/json", AppCreateRequest, required: true), + requestBody: Helpers.request_body("Parameters", AppCreateRequest, required: true), responses: %{ 200 => Operation.response("App", "application/json", AppCreateResponse), 422 => From 06471940e0cb917bb362cbcb9d872ab1336a04cf Mon Sep 17 00:00:00 2001 From: kPherox Date: Tue, 7 Apr 2020 08:44:53 +0000 Subject: [PATCH 167/188] Apply suggestion to test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs --- .../controllers/account_controller/update_credentials_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs index 8687d7995..d78fbc5a1 100644 --- a/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs +++ b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs @@ -298,7 +298,7 @@ test "update fields", %{conn: conn} do ] end - test "update fields by urlencoded", %{conn: conn} do + test "update fields via x-www-form-urlencoded", %{conn: conn} do fields = [ "fields_attributes[1][name]=link", From 5739c498c029914c446656244cdd213a3e358fec Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 8 Apr 2020 18:46:01 +0300 Subject: [PATCH 168/188] fix for gun connections pool --- CHANGELOG.md | 3 +++ lib/pleroma/gun/conn.ex | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6e5d807c..92d1abc4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Mastodon API: Support for `include_types` in `/api/v1/notifications`.

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 delta 78 zcmaFa&Hl2ReM2!LvvHcyv5$372xBxiF*#*3WwQ)XCZ_^1FtarH fSp*0%Wi~lAIWjhrkCq&h>HH>>-^miQ>HPT+&(0jO delta 73 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 literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/chunk-2b9c.cf321c74.js b/priv/static/adminfe/static/js/chunk-2b9c.cf321c74.js new file mode 100644 index 0000000000000000000000000000000000000000..f06da02689e1b032bdc69066f1b2b402f65f48cb GIT binary patch literal 28194 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 a`0#bl{1@F^|8#R>-GB4|WmRy_j{grC5!n_1 literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/chunk-2b9c.cf321c74.js.map b/priv/static/adminfe/static/js/chunk-2b9c.cf321c74.js.map new file mode 100644 index 0000000000000000000000000000000000000000..1ec750dd1ae7f39f7a2465b71d64335349c8f690 GIT binary patch literal 95810 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 G@BcqRY}NJv literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/chunk-46cf.3bd3567a.js b/priv/static/adminfe/static/js/chunk-46cf.3bd3567a.js deleted file mode 100644 index 0795a46b64e5251f4a1bd0ac4b50f77c0103c8e5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9526 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 diff --git a/priv/static/adminfe/static/js/chunk-46ef.215af110.js b/priv/static/adminfe/static/js/chunk-46ef.671cac7d.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-46ef.215af110.js rename to priv/static/adminfe/static/js/chunk-46ef.671cac7d.js index db11c7488fc38c4593d070fae4e33c020e9caf9b..805cdea13fb17f2bb8e378d39781dd66816f6f97 100644 GIT binary patch delta 23 ecmca=bJb>pn;gHHxnXi*vU!SLRpn;gH9p=n~8p`n3ZRvyfWa1qd@@ OIWRY6H8ZnvV8{hTtrJrK diff --git a/priv/static/adminfe/static/js/chunk-4e7d.a40ad735.js b/priv/static/adminfe/static/js/chunk-4e7d.a40ad735.js deleted file mode 100644 index ef2379ed9bbec1a154999a4a3ed175a51e681c19..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23331 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 diff --git a/priv/static/adminfe/static/js/chunk-87b3.4704cadf.js b/priv/static/adminfe/static/js/chunk-87b3.3c11ef09.js similarity index 60% rename from priv/static/adminfe/static/js/chunk-87b3.4704cadf.js rename to priv/static/adminfe/static/js/chunk-87b3.3c11ef09.js index 9766fd7d28215a87e7382ea6b4fb406c1037af6c..3899ff190c68e24bba56309177c54dbba6c4da26 100644 GIT binary patch delta 390 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

+### Fixed +- Gun connections pool `max_connections` option. + ## [2.0.0] - 2019-03-08 ### Security - Mastodon API: Fix being able to request enourmous amount of statuses in timelines leading to DoS. Now limited to 40 per request. diff --git a/lib/pleroma/gun/conn.ex b/lib/pleroma/gun/conn.ex index 20823a765..cd25a2e74 100644 --- a/lib/pleroma/gun/conn.ex +++ b/lib/pleroma/gun/conn.ex @@ -49,8 +49,10 @@ def open(%URI{} = uri, name, opts) do key = "#{uri.scheme}:#{uri.host}:#{uri.port}" + max_connections = pool_opts[:max_connections] || 250 + conn_pid = - if Connections.count(name) < opts[:max_connection] do + if Connections.count(name) < max_connections do do_open(uri, opts) else close_least_used_and_do_open(name, uri, opts) From d067eaa7b3bb76e7fc5ae019d6e00510b657171d Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 8 Apr 2020 22:58:31 +0300 Subject: [PATCH 169/188] formatter.ex: Use Phoenix.HTML for mention/hashtag generation Unlike concatenating strings, this makes sure everything is escaped. Tests had to be changed because Phoenix.HTML runs attributes through Enum.sort before generation for whatever reason. --- lib/pleroma/formatter.ex | 26 ++++++++++++++++--- test/formatter_test.exs | 24 +++++++---------- test/user_test.exs | 2 +- test/web/common_api/common_api_utils_test.exs | 6 ++--- .../update_credentials_test.exs | 4 +-- .../notification_controller_test.exs | 4 +-- test/web/twitter_api/twitter_api_test.exs | 2 +- 7 files changed, 41 insertions(+), 27 deletions(-) diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index e2a658cb3..c44e7fc8b 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -35,9 +35,19 @@ def mention_handler("@" <> nickname, buffer, opts, acc) do nickname_text = get_nickname_text(nickname, opts) link = - ~s(@#{ - nickname_text - }) + Phoenix.HTML.Tag.content_tag( + :span, + Phoenix.HTML.Tag.content_tag( + :a, + ["@", Phoenix.HTML.Tag.content_tag(:span, nickname_text)], + "data-user": id, + class: "u-url mention", + href: ap_id, + rel: "ugc" + ), + class: "h-card" + ) + |> Phoenix.HTML.safe_to_string() {link, %{acc | mentions: MapSet.put(acc.mentions, {"@" <> nickname, user})}} @@ -49,7 +59,15 @@ def mention_handler("@" <> nickname, buffer, opts, acc) do def hashtag_handler("#" <> tag = tag_text, _buffer, _opts, acc) do tag = String.downcase(tag) url = "#{Pleroma.Web.base_url()}/tag/#{tag}" - link = ~s(#{tag_text}) + + link = + Phoenix.HTML.Tag.content_tag(:a, tag_text, + class: "hashtag", + "data-tag": tag, + href: url, + rel: "tag ugc" + ) + |> Phoenix.HTML.safe_to_string() {link, %{acc | tags: MapSet.put(acc.tags, {tag_text, tag})}} end diff --git a/test/formatter_test.exs b/test/formatter_test.exs index cf8441cf6..93fd8eab7 100644 --- a/test/formatter_test.exs +++ b/test/formatter_test.exs @@ -150,13 +150,13 @@ test "gives a replacement for user links, using local nicknames in user links te assert length(mentions) == 3 expected_text = - ~s(@gsimg According to @gsimg According to @archa_eme_, that is @daggsy. Also hello @archa_eme_, that is @daggsy. Also hello @archaeme) + }" href="#{archaeme_remote.ap_id}" rel="ugc">@archaeme) assert expected_text == text end @@ -171,7 +171,7 @@ test "gives a replacement for user links when the user is using Osada" do assert length(mentions) == 1 expected_text = - ~s(@mike test) @@ -187,7 +187,7 @@ test "gives a replacement for single-character local nicknames" do assert length(mentions) == 1 expected_text = - ~s(@o hi) + ~s(@o hi) assert expected_text == text end @@ -209,17 +209,13 @@ test "given the 'safe_mention' option, it will only mention people in the beginn assert mentions == [{"@#{user.nickname}", user}, {"@#{other_user.nickname}", other_user}] assert expected_text == - ~s(@#{user.nickname} @#{user.nickname} @#{ - other_user.nickname - } hey dudes i hate @#{other_user.nickname} hey dudes i hate @#{ - third_user.nickname - }) + }" href="#{third_user.ap_id}" rel="ugc">@#{third_user.nickname}) end test "given the 'safe_mention' option, it will still work without any mention" do diff --git a/test/user_test.exs b/test/user_test.exs index 0479f294d..d39787f35 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1404,7 +1404,7 @@ test "preserves hosts in user links text" do bio = "A.k.a. @nick@domain.com" expected_text = - ~s(A.k.a. @nick@domain.com) diff --git a/test/web/common_api/common_api_utils_test.exs b/test/web/common_api/common_api_utils_test.exs index d383d1714..98cf02d49 100644 --- a/test/web/common_api/common_api_utils_test.exs +++ b/test/web/common_api/common_api_utils_test.exs @@ -159,11 +159,11 @@ test "works for text/markdown with mentions" do {output, _, _} = Utils.format_input(text, "text/markdown") assert output == - ~s(

hello world

another @user__test and @user__test and @user__test google.com paragraph

) + }" href="http://foo.com/user__test" rel="ugc">@user__test google.com paragraph

) end end diff --git a/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs index d78fbc5a1..2d256f63c 100644 --- a/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs +++ b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs @@ -82,9 +82,9 @@ test "updates the user's bio", %{conn: conn} do assert user_data = json_response(conn, 200) assert user_data["note"] == - ~s(I drink #cofe with #cofe with @#{user2.nickname}

suya..) + }" href="#{user2.ap_id}" rel="ugc">@#{user2.nickname}


suya..) end test "updates the user's locking status", %{conn: conn} do diff --git a/test/web/mastodon_api/controllers/notification_controller_test.exs b/test/web/mastodon_api/controllers/notification_controller_test.exs index 344eabb4a..6f1fab069 100644 --- a/test/web/mastodon_api/controllers/notification_controller_test.exs +++ b/test/web/mastodon_api/controllers/notification_controller_test.exs @@ -26,7 +26,7 @@ test "list of notifications" do |> get("/api/v1/notifications") expected_response = - "hi @#{user.nickname}" @@ -45,7 +45,7 @@ test "getting a single notification" do conn = get(conn, "/api/v1/notifications/#{notification.id}") expected_response = - "hi @#{user.nickname}" diff --git a/test/web/twitter_api/twitter_api_test.exs b/test/web/twitter_api/twitter_api_test.exs index 92f9aa0f5..f6e13b661 100644 --- a/test/web/twitter_api/twitter_api_test.exs +++ b/test/web/twitter_api/twitter_api_test.exs @@ -109,7 +109,7 @@ test "it registers a new user and parses mentions in the bio" do {:ok, user2} = TwitterAPI.register_user(data2) expected_text = - ~s(@john test) From c401b00c7885823744183dbd077db9239585d20d Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 9 Apr 2020 04:36:39 +0200 Subject: [PATCH 170/188] ObjectValidators.Types.ObjectID: Fix when URI.parse returns %URL{host: ""} --- .../object_validators/types/object_id.ex | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/types/object_id.ex b/lib/pleroma/web/activity_pub/object_validators/types/object_id.ex index ee10be0b0..f6e749b33 100644 --- a/lib/pleroma/web/activity_pub/object_validators/types/object_id.ex +++ b/lib/pleroma/web/activity_pub/object_validators/types/object_id.ex @@ -6,14 +6,10 @@ def type, do: :string def cast(object) when is_binary(object) do # Host has to be present and scheme has to be an http scheme (for now) case URI.parse(object) do - %URI{host: nil} -> - :error - - %URI{scheme: scheme} when scheme in ["https", "http"] -> - {:ok, object} - - _ -> - :error + %URI{host: nil} -> :error + %URI{host: ""} -> :error + %URI{scheme: scheme} when scheme in ["https", "http"] -> {:ok, object} + _ -> :error end end From 73134e248a031613151df87fdd406580d16dc6b9 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 9 Apr 2020 08:03:21 +0300 Subject: [PATCH 171/188] no changelog entry - bug fixed only in develop --- CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92d1abc4e..b6e5d807c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,9 +20,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Mastodon API: Support for `include_types` in `/api/v1/notifications`.
-### Fixed -- Gun connections pool `max_connections` option. - ## [2.0.0] - 2019-03-08 ### Security - Mastodon API: Fix being able to request enourmous amount of statuses in timelines leading to DoS. Now limited to 40 per request. From c8bfbf511eeca2045267ad4792c35648625788cf Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 9 Apr 2020 10:17:24 +0000 Subject: [PATCH 172/188] Apply suggestion to docs/API/admin_api.md --- docs/API/admin_api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index 179d8c451..b3cf89818 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -400,7 +400,7 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret ```json [ { - `error` // error message + "error": "Appropriate error message here" } ] ``` From 4c60fdcbb1ab06183b8e300cbbb84d70ecd3e25b Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 9 Apr 2020 10:17:31 +0000 Subject: [PATCH 173/188] Apply suggestion to lib/pleroma/web/admin_api/admin_api_controller.ex --- lib/pleroma/web/admin_api/admin_api_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 7b442f6e1..a66db68f3 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -592,7 +592,7 @@ def email_invite(%{assigns: %{user: user}} = conn, %{"email" => email} = params) {:registrations_open, _} -> errors( conn, - {:error, "To send invites you need set `registrations_open` option to false."} + {:error, "To send invites you need to set the `registrations_open` option to false."} ) {:invites_enabled, _} -> From 1cf0d5ab0d579ee4a1a779c308fedb0ab8ec3884 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 9 Apr 2020 10:17:36 +0000 Subject: [PATCH 174/188] Apply suggestion to lib/pleroma/web/admin_api/admin_api_controller.ex --- lib/pleroma/web/admin_api/admin_api_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index a66db68f3..09959b3bf 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -598,7 +598,7 @@ def email_invite(%{assigns: %{user: user}} = conn, %{"email" => email} = params) {:invites_enabled, _} -> errors( conn, - {:error, "To send invites you need set `invites_enabled` option to true."} + {:error, "To send invites you need set to set the `invites_enabled` option to true."} ) end end From 365c34a7a96a9cbd5acb30eb6eedf195eeaff131 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 9 Apr 2020 10:17:44 +0000 Subject: [PATCH 175/188] Apply suggestion to test/web/admin_api/admin_api_controller_test.exs --- 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 32fe69d19..afd894269 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -671,7 +671,7 @@ test "it returns 500 if `invites_enabled` is not enabled", %{conn: conn} do conn = post(conn, "/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") assert json_response(conn, :bad_request) == - "To send invites you need set `invites_enabled` option to true." + "To send invites you need to set the `invites_enabled` option to true." end test "it returns 500 if `registrations_open` is enabled", %{conn: conn} do From 9795ff5b016e74c0e7b94ac2ea28023208d1f8ee Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 9 Apr 2020 10:17:50 +0000 Subject: [PATCH 176/188] Apply suggestion to test/web/admin_api/admin_api_controller_test.exs --- 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 afd894269..e8d11b88c 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -681,7 +681,7 @@ test "it returns 500 if `registrations_open` is enabled", %{conn: conn} do conn = post(conn, "/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") assert json_response(conn, :bad_request) == - "To send invites you need set `registrations_open` option to false." + "To send invites you need to set the `registrations_open` option to false." end end From f20a19de853e8834f7774ee0098a14213bc7427f Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 9 Apr 2020 13:28:54 +0300 Subject: [PATCH 177/188] typo fix --- lib/pleroma/web/admin_api/admin_api_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 09959b3bf..fdbd24acb 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -598,7 +598,7 @@ def email_invite(%{assigns: %{user: user}} = conn, %{"email" => email} = params) {:invites_enabled, _} -> errors( conn, - {:error, "To send invites you need set to set the `invites_enabled` option to true."} + {:error, "To send invites you need to set the `invites_enabled` option to true."} ) end end From d37a102933dbfbb0996546b4d148bbe36fbd4220 Mon Sep 17 00:00:00 2001 From: kPherox Date: Thu, 9 Apr 2020 21:16:29 +0900 Subject: [PATCH 178/188] Fix OTP_VERSION file in docker --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index 29931a5e3..c2f3ad98c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,8 @@ RUN apk add git gcc g++ musl-dev make &&\ mkdir release &&\ mix release --path release +RUN echo "${OTP_VERSION}" > release/OTP_VERSION + FROM alpine:3.11 ARG BUILD_DATE From d545b883eb3c5b79b89a49ccaf9256c31b401145 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 9 Apr 2020 17:08:43 +0400 Subject: [PATCH 179/188] Add `/api/v1/notifications/:id/dismiss` endpoint --- .../controllers/notification_controller.ex | 3 ++- lib/pleroma/web/router.ex | 4 +++- .../notification_controller_test.exs | 18 +++++++++++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex index 0c9218454..a6b4096ec 100644 --- a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex @@ -66,7 +66,8 @@ def clear(%{assigns: %{user: user}} = conn, _params) do json(conn, %{}) end - # POST /api/v1/notifications/dismiss + # POST /api/v1/notifications/:id/dismiss + # POST /api/v1/notifications/dismiss (deprecated) def dismiss(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do with {:ok, _notif} <- Notification.dismiss(user, id) do json(conn, %{}) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 3ecd59cd1..5f5ec1c81 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -352,9 +352,11 @@ defmodule Pleroma.Web.Router do get("/notifications", NotificationController, :index) get("/notifications/:id", NotificationController, :show) + post("/notifications/:id/dismiss", NotificationController, :dismiss) post("/notifications/clear", NotificationController, :clear) - post("/notifications/dismiss", NotificationController, :dismiss) delete("/notifications/destroy_multiple", NotificationController, :destroy_multiple) + # Deprecated: was removed in Mastodon v3, use `/notifications/:id/dismiss` instead + post("/notifications/dismiss", NotificationController, :dismiss) get("/scheduled_statuses", ScheduledActivityController, :index) get("/scheduled_statuses/:id", ScheduledActivityController, :show) diff --git a/test/web/mastodon_api/controllers/notification_controller_test.exs b/test/web/mastodon_api/controllers/notification_controller_test.exs index 6f1fab069..1557937d8 100644 --- a/test/web/mastodon_api/controllers/notification_controller_test.exs +++ b/test/web/mastodon_api/controllers/notification_controller_test.exs @@ -53,7 +53,7 @@ test "getting a single notification" do assert response == expected_response end - test "dismissing a single notification" do + test "dismissing a single notification (deprecated endpoint)" do %{user: user, conn: conn} = oauth_access(["write:notifications"]) other_user = insert(:user) @@ -69,6 +69,22 @@ test "dismissing a single notification" do assert %{} = json_response(conn, 200) end + test "dismissing a single notification" do + %{user: user, conn: conn} = oauth_access(["write:notifications"]) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) + + {:ok, [notification]} = Notification.create_notifications(activity) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/notifications/#{notification.id}/dismiss") + + assert %{} = json_response(conn, 200) + end + test "clearing all notifications" do %{user: user, conn: conn} = oauth_access(["write:notifications", "read:notifications"]) other_user = insert(:user) From 0e8f6d24b87812664d3bb021d17f120686cf2401 Mon Sep 17 00:00:00 2001 From: kPherox Date: Fri, 10 Apr 2020 00:19:09 +0900 Subject: [PATCH 180/188] Create OTP_VERSION file by `mix release` --- Dockerfile | 2 -- mix.exs | 11 ++++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index c2f3ad98c..29931a5e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,8 +12,6 @@ RUN apk add git gcc g++ musl-dev make &&\ mkdir release &&\ mix release --path release -RUN echo "${OTP_VERSION}" > release/OTP_VERSION - FROM alpine:3.11 ARG BUILD_DATE diff --git a/mix.exs b/mix.exs index 3e4c7cbd8..ad2029518 100644 --- a/mix.exs +++ b/mix.exs @@ -37,12 +37,21 @@ def project do pleroma: [ include_executables_for: [:unix], applications: [ex_syslogger: :load, syslog: :load], - steps: [:assemble, ©_files/1, ©_nginx_config/1] + steps: [:assemble, &put_files/1, ©_files/1, ©_nginx_config/1] ] ] ] end + def put_files(%{path: target_path} = release) do + File.write!( + Path.join([target_path, "OTP_VERSION"]), + Pleroma.OTPVersion.version() + ) + + release + end + def copy_files(%{path: target_path} = release) do File.cp_r!("./rel/files", target_path) release From c826d5195f1746449eb369e86a730f14de9fa267 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 9 Apr 2020 23:36:17 +0400 Subject: [PATCH 181/188] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6e5d807c..2f5d8f612 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
API Changes - Mastodon API: Support for `include_types` in `/api/v1/notifications`. +- Mastodon API: Added `/api/v1/notifications/:id/dismiss` endpoint.
## [2.0.0] - 2019-03-08 From 781ac28859596fce5f2fd24ffe1cdf24caaaa2fc Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sun, 15 Mar 2020 17:26:58 +0300 Subject: [PATCH 182/188] changelog.md: add 2.0.1 entry --- CHANGELOG.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f5d8f612..15f0463b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [unreleased] +## [2.0.1] - 2020-03-15 +### Fixed +- 500 errors when no `Accept` header is present if Static-FE is enabled +- Instance panel not being updated immediately due to wrong `Cache-Control` headers +- Statuses posted with BBCode/Markdown having unncessary newlines in Pleroma-FE +- OTP: Fix some settings not being migrated to in-database config properly +- No `Cache-Control` headers on attachment/media proxy requests +- Character limit enforcement being off by 1 +- Mastodon Streaming API: hashtag timelines not working + ### Changed -- **Breaking:** BBCode and Markdown formatters will no longer return any `\n` and only use `
` for newlines +- BBCode and Markdown formatters will no longer return any `\n` and only use `
` for newlines +- Mastodon API: Allow registration without email if email verification is not enabled ### Removed - **Breaking:** removed `with_move` parameter from notifications timeline. From 2a08f44b026bae611064b6ac459e7df16e4a36f9 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Mon, 16 Mar 2020 00:50:03 +0300 Subject: [PATCH 183/188] CHANGELOG.md: Add upgrade notes for 2.0.1 --- CHANGELOG.md | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15f0463b2..8c976228c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [unreleased] +### Removed +- **Breaking:** removed `with_move` parameter from notifications timeline. + +### Added +- NodeInfo: `pleroma:api/v1/notifications:include_types_filter` to the `features` list. +- NodeInfo: `pleroma_emoji_reactions` to the `features` list. +- Configuration: `:restrict_unauthenticated` setting, restrict access for unauthenticated users to timelines (public and federate), user profiles and statuses. +- New HTTP adapter [gun](https://github.com/ninenines/gun). Gun adapter requires minimum OTP version of 22.2 otherwise Pleroma won’t start. For hackney OTP update is not required. +
+ API Changes +- Mastodon API: Support for `include_types` in `/api/v1/notifications`. +- Mastodon API: Added `/api/v1/notifications/:id/dismiss` endpoint. +
+ ## [2.0.1] - 2020-03-15 ### Fixed - 500 errors when no `Accept` header is present if Static-FE is enabled @@ -17,19 +32,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - BBCode and Markdown formatters will no longer return any `\n` and only use `
` for newlines - Mastodon API: Allow registration without email if email verification is not enabled -### Removed -- **Breaking:** removed `with_move` parameter from notifications timeline. +### Upgrade notes +#### Nginx only +1. Remove `proxy_ignore_headers Cache-Control;` and `proxy_hide_header Cache-Control;` from your config. -### Added -- NodeInfo: `pleroma:api/v1/notifications:include_types_filter` to the `features` list. -- NodeInfo: `pleroma_emoji_reactions` to the `features` list. -- Configuration: `:restrict_unauthenticated` setting, restrict access for unauthenticated users to timelines (public and federate), user profiles and statuses. -- New HTTP adapter [gun](https://github.com/ninenines/gun). Gun adapter requires minimum OTP version of 22.2 otherwise Pleroma won’t start. For hackney OTP update is not required. -
- API Changes -- Mastodon API: Support for `include_types` in `/api/v1/notifications`. -- Mastodon API: Added `/api/v1/notifications/:id/dismiss` endpoint. -
+#### Everyone +1. Run database migrations (inside Pleroma directory): + - OTP: `./bin/pleroma_ctl migrate` + - From Source: `mix ecto.migrate` +2. Restart Pleroma ## [2.0.0] - 2019-03-08 ### Security From 7306d2d06942f7912fd42809b1feb9ac43089012 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Tue, 31 Mar 2020 13:59:26 +0300 Subject: [PATCH 184/188] CHANGELOG.md: Add 2.0.2 entry --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c976228c..6942ad0bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Mastodon API: Added `/api/v1/notifications/:id/dismiss` endpoint. +## [2.0.2] - 2020-03-31 +### Fixed +- Blocked/muted users still generating push notifications +- Input textbox for bio ignoring newlines +- OTP: Inability to use PostgreSQL databases with SSL +- `user delete_activities` breaking when trying to delete already deleted posts + +### Added +- Admin API: `PATCH /api/pleroma/admin/users/:nickname/update_credentials` + ## [2.0.1] - 2020-03-15 +### Security +- Static-FE: Fix remote posts not being sanitized + ### Fixed - 500 errors when no `Accept` header is present if Static-FE is enabled - Instance panel not being updated immediately due to wrong `Cache-Control` headers From 0b8f9a66aefdf4c9e2b7c1fa931e19cd724b6b4b Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 2 Apr 2020 23:37:14 +0300 Subject: [PATCH 185/188] CHANGELOG.md: add entries for funkwhale-related changes --- CHANGELOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6942ad0bf..8eed9cf7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,14 +19,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [2.0.2] - 2020-03-31 +### Added +- Support for Funkwhale's `Audio` activity +- Admin API: `PATCH /api/pleroma/admin/users/:nickname/update_credentials` + ### Fixed - Blocked/muted users still generating push notifications - Input textbox for bio ignoring newlines - OTP: Inability to use PostgreSQL databases with SSL - `user delete_activities` breaking when trying to delete already deleted posts - -### Added -- Admin API: `PATCH /api/pleroma/admin/users/:nickname/update_credentials` +- Incorrect URL for Funkwhale channels ## [2.0.1] - 2020-03-15 ### Security From adeb82e4966a505e9ac65743e6336db27558e38f Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 8 Apr 2020 00:38:48 +0300 Subject: [PATCH 186/188] CHANGELOG.md: add 2.0.2 update notes --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eed9cf7d..408b932b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `user delete_activities` breaking when trying to delete already deleted posts - Incorrect URL for Funkwhale channels +### Upgrade notes +1. Restart Pleroma + ## [2.0.1] - 2020-03-15 ### Security - Static-FE: Fix remote posts not being sanitized From 9abf13abe05f3f53bdf21d4d97242e571b1767c6 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 8 Apr 2020 00:39:55 +0300 Subject: [PATCH 187/188] CHANGELOG.md: update 2.0.2 release date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 408b932b8..bac69ad6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,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. -## [2.0.2] - 2020-03-31 +## [2.0.2] - 2020-04-08 ### Added - Support for Funkwhale's `Audio` activity - Admin API: `PATCH /api/pleroma/admin/users/:nickname/update_credentials` From c2aad36aa86694d4131adb2ed47441beca2ab2e8 Mon Sep 17 00:00:00 2001 From: kPherox Date: Thu, 9 Apr 2020 23:19:41 +0000 Subject: [PATCH 188/188] Rename function --- mix.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index ad2029518..a1fcde564 100644 --- a/mix.exs +++ b/mix.exs @@ -37,13 +37,13 @@ def project do pleroma: [ include_executables_for: [:unix], applications: [ex_syslogger: :load, syslog: :load], - steps: [:assemble, &put_files/1, ©_files/1, ©_nginx_config/1] + steps: [:assemble, &put_otp_version/1, ©_files/1, ©_nginx_config/1] ] ] ] end - def put_files(%{path: target_path} = release) do + def put_otp_version(%{path: target_path} = release) do File.write!( Path.join([target_path, "OTP_VERSION"]), Pleroma.OTPVersion.version()